mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-07-14 05:06:14 +00:00
e39d6a0f0d
Multi-agent audit + fix pass covering bugs, security, dead config wiring,
and missing features. All quality gates green: build/vet/test -race/govulncheck/
golangci-lint. Frontend tests 47/47.
SECURITY & CORRECTNESS
- Scanner pipeline fail-closed: cache.go scan timeout now 503 (was goto servePkg);
scanner.go all-scanners-fail saves ScanStatusError; CheckVulnerabilities blocks
on missing/error result instead of allowing.
- Scanner argv corrections: govulncheck source-mode + go.mod discovery;
npm-audit generates lockfile via npm install --package-lock-only --ignore-scripts;
pip-audit per-extension dispatch (wheel direct / sdist extract+pyproject).
- GHSA version-range filtering implemented (was always-include); url.QueryEscape
on package name.
- pypi SSRF closed via host allowlist on original_url; url.QueryEscape on
rewriteURL output.
- Path traversal: cache temp file uses os.CreateTemp; smb keyToPath sanitized;
filesystem keyToPath returns error + filepath.Abs prefix-verify.
- Goroutine leaks plugged: cache.cleanupWorker stop channel; auth.ValidationCache
Stop() with sync.Once; ws.unregister buffered with non-blocking send.
- WebSocket double-close panic fixed via sync.Once Client.closeSend().
- Race conditions: gormstore.registryCache sync.RWMutex (was concurrent map
panic); auth.LastUsedAt no longer mutated under RLock; analytics strict
lock-ordering invariant (statsMu before downloadsMu).
- Auth validator fails CLOSED on transport/unknown-status errors (was returning
true,err 'allow cache fallback').
- Credential cache hash full SHA256 (was 8-byte truncate; eliminates 2^32
collision risk).
- filesystem.fs.used incremented after successful rename (no quota inflation
race).
- gormstore aggregation events deleted in same tx as insert (no double-counting).
- gormstore.SavePackage uses Updates(map) so zero-value security flags
(RequiresAuth=false) can be cleared.
- partition_manager validates partition name regex before DROP TABLE.
- vcs/git: version regex validation rejects --option-injection; checkout uses
--detach -- separator; removed TrimPrefix(repo,'v') that corrupted vault/
vitess/vim-go.
- WebSocket CheckOrigin allowlist (was return true; CSWSH closed); same-origin
default + ServerConfig.AllowedOrigins.
- fiber CVE GO-2026-4543 patched (v2.52.10 -> v2.52.12).
FUNCTIONAL FEATURES
- API key DB persistence: APIKeyModel + migration 202604280002, full CRUD,
async LastUsedAt updates with WaitGroup-tracked goroutines drained on Close.
- Admin bootstrap via GOHOARDER_BOOTSTRAP_ADMIN_KEY env var; idempotent
(skipped when non-revoked admin already exists).
- Auth middleware factory in pkg/app: RequireAuth, RequireRole, RequirePermission,
OptionalAuth. Mounted on proxy + read APIs when Auth.Enabled=true.
DELETE /api/packages/* requires admin role. /health public for k8s probes.
- NFS storage backend: pkg/storage/nfs wraps filesystem with /proc/mounts
detection (Linux), post-write fsync, read-after-write health probe.
- WebSocket real-time pipeline: pkg/events Broadcaster interface; cache + scanner
managers emit EventPackageCached / EventPackageDownloaded / EventScanComplete;
app.go runs 30s EventStatsUpdate ticker. Frontend has full WS client (reconnect
with exponential backoff 1s->30s, 25s heartbeat, subscriber pattern), Pinia
realtime store, Dashboard live indicator + activity feed + reactive stats
override.
- TLS termination: fiber.ListenTLS when Server.TLS.Enabled.
- Pre-warming: Prewarming.Enabled flag wired (was hardcoded false); Interval,
MaxConcurrent, TopPackages plumbed.
- NetworkConfig wired (timeouts, retry, rate-limit, circuit-breaker).
- AuthConfig.BcryptCost wired.
- Security.Scanners.Static.MaxPackageSize plumbed to cache.io.LimitReader.
- Handlers.{Go,NPM,PyPI}.Enabled gates route mounting.
- Graceful shutdown: authManager.Close drains async writes.
LINT/HYGIENE
- 80 golangci-lint issues resolved: errcheck (Close/Write/RemoveAll wrapped),
gofmt, gosec G104/G306, govet shadow renames + fieldalignment reorders,
staticcheck ST1000 pkg comments + QF1003 tagged switches + QF1008 embedded
field selectors + QF1001 De Morgan's law.
BEHAVIOR CHANGES
- Scanner now fail-closed: deployments without scanner binaries
(trivy/govulncheck/npm-audit/pip-audit/grype) BLOCK packages instead of
serving unscanned. Add binaries or plan a future allow-on-scan-error flag.
- WS origin defaults to same-origin; cross-origin dashboards must set
server.allowed_origins.
- Validator no longer fails open; private packages won't serve from cache when
validation can't be performed.
- download_events retention dropped from 24h to ~5min (deleted in agg tx).
Migration 202604280001 purges pre-upgrade events.
- DELETE /api/packages/* requires admin role when auth enabled.
NEW PACKAGES
- pkg/events - Broadcaster interface
- pkg/storage/nfs - NFS-aware filesystem wrapper
DEFERRED (out of scope this pass)
- Static scanner implementation (config defines AllowedLicenses/MaxPackageSize/
BlockSuspicious but pkg/scanner/static/ does not exist).
- AuthConfig.AuditLog wiring (no audit log infra yet).
- CacheConfig.TTLOverrides passthrough (would touch cache.Config beyond
integrator scope).
381 lines
11 KiB
Go
381 lines
11 KiB
Go
// Package osv implements a vulnerability scanner backed by the OSV.dev API.
|
|
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 func() { _ = 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 func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
|
|
|
|
log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed")
|
|
return nil
|
|
}
|