Files
gohoarder/pkg/scanner/osv/osv.go
T
lukaszraczylo c4bb2f6e3a fix: clean Go module paths before calling OSV API
- OSV API expects clean module paths like 'gorm.io/driver/sqlite'
- Cache keys include Go proxy format like 'gorm.io/driver/sqlite/@v/v1.6.0.zip'
- Added cleanGoModuleName() to extract module path and version
- Strips /@v/ prefix and .zip/.mod/.info suffixes
- Fixes 404 errors when scanning Go modules

Resolves: OSV API returning 404 for Go module scans
2026-01-04 13:07:31 +00:00

380 lines
11 KiB
Go

package osv
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/rs/zerolog/log"
)
const (
// ScannerName is the name of this scanner
ScannerName = "osv"
defaultOSVAPIURL = "https://api.osv.dev/v1/query"
)
// Scanner implements the Scanner interface using OSV.dev API
type Scanner struct {
httpClient *http.Client
config config.OSVConfig
}
// OSVRequest represents the request structure for OSV API
type OSVRequest struct {
Package PackageInfo `json:"package"`
Version string `json:"version,omitempty"`
}
// PackageInfo contains package ecosystem and name
type PackageInfo struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"` // npm, PyPI, Go, etc.
}
// OSVResponse represents the response from OSV API
type OSVResponse struct {
Vulns []OSVVulnerability `json:"vulns"`
}
// OSVVulnerability represents a vulnerability in OSV format
type OSVVulnerability struct {
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
ID string `json:"id"`
Summary string `json:"summary"`
Details string `json:"details"`
Severity []OSVSeverity `json:"severity,omitempty"`
References []OSVReference `json:"references,omitempty"`
Affected []OSVAffected `json:"affected"`
}
// OSVSeverity represents severity information
type OSVSeverity struct {
Type string `json:"type"` // CVSS_V3, etc.
Score string `json:"score"` // Severity score
}
// OSVReference represents a reference link
type OSVReference struct {
Type string `json:"type"` // WEB, ADVISORY, etc.
URL string `json:"url"`
}
// OSVAffected represents affected package versions
type OSVAffected struct {
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,omitempty"`
Package PackageInfo `json:"package"`
Ranges []OSVRange `json:"ranges,omitempty"`
Versions []string `json:"versions,omitempty"`
}
// OSVRange represents version ranges
type OSVRange struct {
Type string `json:"type"` // SEMVER, GIT, etc.
Events []OSVEvent `json:"events"`
}
// OSVEvent represents version range events
type OSVEvent struct {
Introduced string `json:"introduced,omitempty"`
Fixed string `json:"fixed,omitempty"`
LastAffected string `json:"last_affected,omitempty"`
}
// New creates a new OSV scanner
func New(cfg config.OSVConfig) *Scanner {
apiURL := cfg.APIURL
if apiURL == "" {
apiURL = defaultOSVAPIURL
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
return &Scanner{
config: cfg,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
// Name returns the scanner name
func (s *Scanner) Name() string {
return ScannerName
}
// Scan scans a package for vulnerabilities using OSV.dev API
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
// Convert registry to OSV ecosystem
ecosystem := s.registryToEcosystem(registry)
// Clean package name and version for Go modules
// Go proxy cache keys include /@v/version.suffix which OSV doesn't understand
cleanName, cleanVersion := s.cleanGoModuleName(packageName, version, ecosystem)
// Build request
req := OSVRequest{
Package: PackageInfo{
Name: cleanName,
Ecosystem: ecosystem,
},
Version: cleanVersion,
}
// Marshal request
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal OSV request: %w", err)
}
// Create HTTP request
apiURL := s.config.APIURL
if apiURL == "" {
apiURL = defaultOSVAPIURL
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create OSV request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Execute request
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("OSV API request failed: %w", err)
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read OSV response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OSV API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var osvResp OSVResponse
if err := json.Unmarshal(body, &osvResp); err != nil {
return nil, fmt.Errorf("failed to parse OSV response: %w", err)
}
// Convert to metadata.ScanResult
return s.convertOSVResult(&osvResp, registry, packageName, version), nil
}
// registryToEcosystem converts our registry name to OSV ecosystem
func (s *Scanner) registryToEcosystem(registry string) string {
switch strings.ToLower(registry) {
case "npm":
return "npm"
case "pypi":
return "PyPI"
case "go":
return "Go"
case "maven":
return "Maven"
case "nuget":
return "NuGet"
case "cargo", "crates":
return "crates.io"
case "rubygems":
return "RubyGems"
default:
return registry
}
}
// cleanGoModuleName cleans Go module cache keys to extract the actual module path and version
// Go proxy cache keys include /@v/version.suffix patterns that need to be cleaned
// Examples:
// - "gorm.io/driver/sqlite/@v/v1.6.0.zip" -> "gorm.io/driver/sqlite", "v1.6.0"
// - "github.com/pkg/errors/@v/v0.9.1.mod" -> "github.com/pkg/errors", "v0.9.1"
// - "regular-package" -> "regular-package", "version" (unchanged for non-Go)
func (s *Scanner) cleanGoModuleName(packageName, version, ecosystem string) (string, string) {
// Only clean for Go modules
if ecosystem != "Go" {
return packageName, version
}
// Check if package name contains /@v/ pattern (Go module proxy format)
if strings.Contains(packageName, "/@v/") {
// Split on /@v/ to get the module path
parts := strings.Split(packageName, "/@v/")
if len(parts) == 2 {
// parts[0] is the clean module path (e.g., "gorm.io/driver/sqlite")
// parts[1] might be "v1.6.0.zip" or "v1.6.0.mod" or "v1.6.0.info"
cleanName := parts[0]
// Extract version from the second part if version wasn't already clean
// Remove file suffixes like .zip, .mod, .info
versionPart := parts[1]
versionPart = strings.TrimSuffix(versionPart, ".zip")
versionPart = strings.TrimSuffix(versionPart, ".mod")
versionPart = strings.TrimSuffix(versionPart, ".info")
// Use the extracted version if it looks valid, otherwise use the provided version
if versionPart != "" && strings.HasPrefix(versionPart, "v") {
return cleanName, versionPart
}
return cleanName, version
}
}
// Also clean version of any file suffixes
cleanVersion := version
cleanVersion = strings.TrimSuffix(cleanVersion, ".zip")
cleanVersion = strings.TrimSuffix(cleanVersion, ".mod")
cleanVersion = strings.TrimSuffix(cleanVersion, ".info")
return packageName, cleanVersion
}
// convertOSVResult converts OSV response to metadata.ScanResult
func (s *Scanner) convertOSVResult(osvResp *OSVResponse, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0, len(osvResp.Vulns))
severityCounts := make(map[string]int)
for _, vuln := range osvResp.Vulns {
// Determine severity from various sources
severity := s.determineSeverity(&vuln)
severityCounts[severity]++
// Extract references
references := make([]string, 0, len(vuln.References))
for _, ref := range vuln.References {
references = append(references, ref.URL)
}
// Find fixed version
fixedVersion := s.findFixedVersion(&vuln, version)
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
ID: vuln.ID,
Severity: severity,
Title: vuln.Summary,
Description: vuln.Details,
References: references,
FixedIn: fixedVersion,
})
}
// Determine overall status
status := metadata.ScanStatusClean
if len(vulnerabilities) > 0 {
status = metadata.ScanStatusVulnerable
}
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: s.Name(),
ScannedAt: time.Now(),
Status: status,
VulnerabilityCount: len(vulnerabilities),
Vulnerabilities: vulnerabilities,
Details: map[string]interface{}{
"ecosystem": s.registryToEcosystem(registry),
"severity_counts": severityCounts,
},
}
}
// determineSeverity extracts severity from OSV vulnerability
func (s *Scanner) determineSeverity(vuln *OSVVulnerability) string {
var rawSeverity string
// Try to get severity from CVSS
for _, sev := range vuln.Severity {
if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" {
// Parse CVSS score to severity
score := sev.Score
if strings.Contains(strings.ToUpper(score), "CRITICAL") {
rawSeverity = "CRITICAL"
} else if strings.Contains(strings.ToUpper(score), "HIGH") {
rawSeverity = "HIGH"
} else if strings.Contains(strings.ToUpper(score), "MEDIUM") || strings.Contains(strings.ToUpper(score), "MODERATE") {
rawSeverity = "MODERATE"
} else if strings.Contains(strings.ToUpper(score), "LOW") {
rawSeverity = "LOW"
}
if rawSeverity != "" {
break
}
}
}
// Check database_specific for severity if not found in CVSS
if rawSeverity == "" && vuln.DatabaseSpecific != nil {
if sev, ok := vuln.DatabaseSpecific["severity"].(string); ok {
rawSeverity = sev
}
}
// Default to MODERATE if unknown
if rawSeverity == "" {
rawSeverity = "MODERATE"
}
// Normalize to standard severity values
return metadata.NormalizeSeverity(rawSeverity)
}
// findFixedVersion extracts the fixed version from OSV affected ranges
func (s *Scanner) findFixedVersion(vuln *OSVVulnerability, currentVersion string) string {
for _, affected := range vuln.Affected {
for _, r := range affected.Ranges {
for _, event := range r.Events {
if event.Fixed != "" {
return event.Fixed
}
}
}
}
return ""
}
// Health checks if OSV API is reachable
func (s *Scanner) Health(ctx context.Context) error {
// Make a simple request to check API availability
apiURL := s.config.APIURL
if apiURL == "" {
apiURL = defaultOSVAPIURL
}
req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(apiURL, "/query", "", 1), nil)
if err != nil {
return fmt.Errorf("failed to create health check request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("OSV API not reachable: %w", err)
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed")
return nil
}