This commit is contained in:
2026-01-02 04:02:02 +00:00
commit 3b8e171fdb
117 changed files with 21570 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
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 {
config config.OSVConfig
httpClient *http.Client
}
// 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 {
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"`
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
}
// 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 {
Package PackageInfo `json:"package"`
Ranges []OSVRange `json:"ranges,omitempty"`
Versions []string `json:"versions,omitempty"`
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,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)
// Build request
req := OSVRequest{
Package: PackageInfo{
Name: packageName,
Ecosystem: ecosystem,
},
Version: version,
}
// 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()
// 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
}
}
// 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 {
// 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") {
return "CRITICAL"
} else if strings.Contains(strings.ToUpper(score), "HIGH") {
return "HIGH"
} else if strings.Contains(strings.ToUpper(score), "MEDIUM") {
return "MEDIUM"
} else if strings.Contains(strings.ToUpper(score), "LOW") {
return "LOW"
}
}
}
// Check database_specific for severity
if vuln.DatabaseSpecific != nil {
if sev, ok := vuln.DatabaseSpecific["severity"].(string); ok {
return strings.ToUpper(sev)
}
}
// Default to MEDIUM if unknown
return "MEDIUM"
}
// 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()
log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed")
return nil
}
+139
View File
@@ -0,0 +1,139 @@
package scanner
import (
"context"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/rs/zerolog/log"
)
// RescanWorker handles periodic re-scanning of cached packages
type RescanWorker struct {
manager *Manager
metadataStore metadata.MetadataStore
interval time.Duration
stopCh chan struct{}
}
// NewRescanWorker creates a new rescan worker
func NewRescanWorker(manager *Manager, metadataStore metadata.MetadataStore, interval time.Duration) *RescanWorker {
return &RescanWorker{
manager: manager,
metadataStore: metadataStore,
interval: interval,
stopCh: make(chan struct{}),
}
}
// Start begins the periodic re-scanning process
func (w *RescanWorker) Start(ctx context.Context) {
if !w.manager.enabled || w.interval == 0 {
log.Info().Msg("Rescan worker disabled")
return
}
log.Info().
Dur("interval", w.interval).
Msg("Starting package rescan worker")
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
// Run initial scan immediately
w.rescanPackages(ctx)
for {
select {
case <-ticker.C:
w.rescanPackages(ctx)
case <-w.stopCh:
log.Info().Msg("Rescan worker stopped")
return
case <-ctx.Done():
log.Info().Msg("Rescan worker stopped (context cancelled)")
return
}
}
}
// Stop stops the rescan worker
func (w *RescanWorker) Stop() {
close(w.stopCh)
}
// rescanPackages re-scans packages that need updating
func (w *RescanWorker) rescanPackages(ctx context.Context) {
log.Info().Msg("Starting package rescan cycle")
// Get all packages
packages, err := w.metadataStore.ListPackages(ctx, &metadata.ListOptions{})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages for rescan")
return
}
scanned := 0
skipped := 0
failed := 0
for _, pkg := range packages {
// Check if package needs rescanning
needsRescan, err := w.needsRescan(ctx, pkg)
if err != nil {
log.Error().
Err(err).
Str("package", pkg.Name).
Str("version", pkg.Version).
Msg("Failed to check rescan status")
failed++
continue
}
if !needsRescan {
skipped++
continue
}
// Rescan the package
// Note: We need the file path - we'll need to reconstruct it or get it from storage
// For now, we'll just log and skip actual rescanning
log.Info().
Str("registry", pkg.Registry).
Str("package", pkg.Name).
Str("version", pkg.Version).
Msg("Package needs rescanning")
// TODO: Implement actual rescanning by:
// 1. Retrieving package file from storage
// 2. Scanning it
// This would require access to storage backend
scanned++
}
log.Info().
Int("total", len(packages)).
Int("scanned", scanned).
Int("skipped", skipped).
Int("failed", failed).
Msg("Rescan cycle completed")
}
// needsRescan checks if a package needs to be rescanned
func (w *RescanWorker) needsRescan(ctx context.Context, pkg *metadata.Package) (bool, error) {
// Get latest scan result
scanResult, err := w.metadataStore.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version)
if err != nil {
// No scan result - needs scanning
return true, nil
}
// Check if scan is older than rescan interval
timeSinceLastScan := time.Since(scanResult.ScannedAt)
if timeSinceLastScan >= w.interval {
return true, nil
}
return false, nil
}
+432
View File
@@ -0,0 +1,432 @@
package scanner
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/gohoarder/pkg/config"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/osv"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy"
"github.com/rs/zerolog/log"
)
// Scanner defines the interface for security scanners
type Scanner interface {
// Name returns the scanner name
Name() string
// Scan scans a package for vulnerabilities
Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error)
// Health checks scanner health
Health(ctx context.Context) error
}
// DatabaseUpdater is implemented by scanners that need database updates
type DatabaseUpdater interface {
UpdateDatabase(ctx context.Context) error
}
// Manager manages multiple security scanners
type Manager struct {
scanners []Scanner
enabled bool
config config.SecurityConfig
metadataStore metadata.MetadataStore
}
// New creates a new scanner manager with configured scanners
func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) {
manager := &Manager{
scanners: make([]Scanner, 0),
enabled: cfg.Enabled,
config: cfg,
metadataStore: metadataStore,
}
if !cfg.Enabled {
log.Info().Msg("Security scanning disabled")
return manager, nil
}
// Initialize Trivy scanner
if cfg.Scanners.Trivy.Enabled {
trivyScanner := trivy.New(cfg.Scanners.Trivy)
manager.RegisterScanner(trivyScanner)
log.Info().Msg("Trivy scanner enabled")
// Update database on startup if configured
if cfg.UpdateDBOnStartup {
if err := trivyScanner.UpdateDatabase(context.Background()); err != nil {
log.Warn().Err(err).Msg("Failed to update Trivy database on startup")
}
}
}
// Initialize OSV scanner
if cfg.Scanners.OSV.Enabled {
osvScanner := osv.New(cfg.Scanners.OSV)
manager.RegisterScanner(osvScanner)
log.Info().Msg("OSV scanner enabled")
}
if len(manager.scanners) == 0 {
log.Warn().Msg("Security scanning enabled but no scanners configured")
}
return manager, nil
}
// RegisterScanner registers a scanner
func (m *Manager) RegisterScanner(scanner Scanner) {
m.scanners = append(m.scanners, scanner)
}
// ScanPackage scans a package using all registered scanners and saves results
func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, version string, filePath string) error {
if !m.enabled {
return nil
}
log.Info().
Str("registry", registry).
Str("package", packageName).
Str("version", version).
Msg("Starting security scan")
// Collect results from all scanners
var scanResults []*metadata.ScanResult
scannerNames := make([]string, 0)
for _, scanner := range m.scanners {
result, err := scanner.Scan(ctx, registry, packageName, version, filePath)
if err != nil {
log.Error().
Err(err).
Str("scanner", scanner.Name()).
Str("package", packageName).
Msg("Scanner failed")
continue
}
scanResults = append(scanResults, result)
scannerNames = append(scannerNames, scanner.Name())
log.Info().
Str("scanner", scanner.Name()).
Str("package", packageName).
Str("status", string(result.Status)).
Int("vulnerabilities", result.VulnerabilityCount).
Msg("Scan completed")
}
// If no scanners succeeded, return
if len(scanResults) == 0 {
log.Warn().
Str("package", packageName).
Msg("All scanners failed, no results to save")
return nil
}
// Merge and deduplicate results from all scanners
mergedResult := m.mergeResults(scanResults, scannerNames)
// Save consolidated result to metadata store
if err := m.metadataStore.SaveScanResult(ctx, mergedResult); err != nil {
log.Error().
Err(err).
Str("package", packageName).
Msg("Failed to save consolidated scan result")
return err
}
log.Info().
Str("package", packageName).
Str("status", string(mergedResult.Status)).
Int("total_vulnerabilities", mergedResult.VulnerabilityCount).
Int("unique_cves", len(mergedResult.Vulnerabilities)).
Strs("scanners", scannerNames).
Msg("Consolidated scan results saved")
return nil
}
// mergeResults merges and deduplicates scan results from multiple scanners
func (m *Manager) mergeResults(results []*metadata.ScanResult, scannerNames []string) *metadata.ScanResult {
if len(results) == 0 {
return nil
}
// Use first result as base
merged := &metadata.ScanResult{
ID: results[0].ID,
Registry: results[0].Registry,
PackageName: results[0].PackageName,
PackageVersion: results[0].PackageVersion,
Scanner: strings.Join(scannerNames, "+"), // Combined scanner name
ScannedAt: results[0].ScannedAt,
Status: metadata.ScanStatusClean,
Vulnerabilities: make([]metadata.Vulnerability, 0),
Details: make(map[string]interface{}),
}
// Use map for deduplication - key is CVE ID in uppercase
vulnMap := make(map[string]*metadata.Vulnerability)
severityCounts := make(map[string]int)
// Merge vulnerabilities from all scanners
for i, result := range results {
scannerName := scannerNames[i]
// Track scanner details
merged.Details[scannerName] = result.Details
// Add/merge vulnerabilities
for _, vuln := range result.Vulnerabilities {
cveKey := strings.ToUpper(vuln.ID)
// Check if CVE already exists
if existing, exists := vulnMap[cveKey]; exists {
// CVE found by multiple scanners - merge information
log.Debug().
Str("cve", vuln.ID).
Strs("existing_scanners", existing.DetectedBy).
Str("new_scanner", scannerName).
Msg("CVE found by multiple scanners, merging")
// Add scanner to DetectedBy list
existing.DetectedBy = append(existing.DetectedBy, scannerName)
// Prefer higher severity if different
if m.compareSeverity(vuln.Severity, existing.Severity) > 0 {
existing.Severity = vuln.Severity
}
// Merge references (deduplicate URLs)
refSet := make(map[string]bool)
for _, ref := range existing.References {
refSet[ref] = true
}
for _, ref := range vuln.References {
if !refSet[ref] {
existing.References = append(existing.References, ref)
refSet[ref] = true
}
}
// Prefer fixed_in version if not already set
if existing.FixedIn == "" && vuln.FixedIn != "" {
existing.FixedIn = vuln.FixedIn
}
} else {
// New CVE - add to map
vulnCopy := vuln
vulnCopy.DetectedBy = []string{scannerName}
vulnMap[cveKey] = &vulnCopy
}
}
// Update status to worst case
if result.Status == metadata.ScanStatusVulnerable {
merged.Status = metadata.ScanStatusVulnerable
} else if result.Status == metadata.ScanStatusPending && merged.Status != metadata.ScanStatusVulnerable {
merged.Status = metadata.ScanStatusPending
}
}
// Convert map to slice and count severities
for _, vuln := range vulnMap {
merged.Vulnerabilities = append(merged.Vulnerabilities, *vuln)
severityCounts[strings.ToUpper(vuln.Severity)]++
}
// Update counts
merged.VulnerabilityCount = len(merged.Vulnerabilities)
merged.Details["severity_counts"] = severityCounts
merged.Details["deduplication_summary"] = fmt.Sprintf(
"Merged results from %d scanners (%s)",
len(scannerNames),
strings.Join(scannerNames, ", "),
)
return merged
}
// compareSeverity returns >0 if s1 is more severe than s2, <0 if less, 0 if equal
func (m *Manager) compareSeverity(s1, s2 string) int {
severityOrder := map[string]int{
"CRITICAL": 4,
"HIGH": 3,
"MEDIUM": 2,
"LOW": 1,
"UNKNOWN": 0,
}
v1 := severityOrder[strings.ToUpper(s1)]
v2 := severityOrder[strings.ToUpper(s2)]
return v1 - v2
}
// CheckVulnerabilities checks if a package exceeds vulnerability thresholds
func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (bool, string, error) {
if !m.enabled {
return false, "", nil
}
// Get active CVE bypasses from database
bypasses, err := m.metadataStore.GetActiveCVEBypasses(ctx)
if err != nil {
log.Warn().Err(err).Msg("Failed to get CVE bypasses, continuing without bypasses")
bypasses = []*metadata.CVEBypass{} // Continue without bypasses
}
// Check if entire package is bypassed
packageKey := fmt.Sprintf("%s/%s@%s", registry, packageName, version)
packageKeyNoVersion := fmt.Sprintf("%s/%s", registry, packageName)
for _, bypass := range bypasses {
if bypass.Type == metadata.BypassTypePackage && bypass.Active {
if bypass.Target == packageKey || bypass.Target == packageKeyNoVersion {
log.Info().
Str("package", packageKey).
Str("bypass_id", bypass.ID).
Str("reason", bypass.Reason).
Time("expires_at", bypass.ExpiresAt).
Msg("Package bypassed by admin")
return false, "", nil
}
}
}
// Get latest scan result
result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version)
if err != nil {
// No scan result found - allow download (will be scanned after)
return false, "", nil
}
// Build set of bypassed CVEs for fast lookup
bypassedCVEs := make(map[string]*metadata.CVEBypass)
for _, bypass := range bypasses {
if bypass.Type == metadata.BypassTypeCVE && bypass.Active {
// Check if bypass applies to this package (if AppliesTo is set)
if bypass.AppliesTo != "" && bypass.AppliesTo != packageKey && bypass.AppliesTo != packageKeyNoVersion {
continue // This bypass doesn't apply to this package
}
bypassedCVEs[strings.ToUpper(bypass.Target)] = bypass
}
}
// Count vulnerabilities by severity, excluding bypassed CVEs
severityCounts := make(map[string]int)
for _, vuln := range result.Vulnerabilities {
// Check if this CVE is bypassed
if bypass, ok := bypassedCVEs[strings.ToUpper(vuln.ID)]; ok {
log.Debug().
Str("cve", vuln.ID).
Str("package", packageName).
Str("bypass_id", bypass.ID).
Str("reason", bypass.Reason).
Time("expires_at", bypass.ExpiresAt).
Msg("CVE bypassed by admin")
continue
}
severityCounts[strings.ToUpper(vuln.Severity)]++
}
// Check against thresholds
thresholds := m.config.BlockThresholds
// Check critical
if thresholds.Critical >= 0 && severityCounts["CRITICAL"] > thresholds.Critical {
return true, fmt.Sprintf("Package has %d CRITICAL vulnerabilities (threshold: %d)",
severityCounts["CRITICAL"], thresholds.Critical), nil
}
// Check high
if thresholds.High >= 0 && severityCounts["HIGH"] > thresholds.High {
return true, fmt.Sprintf("Package has %d HIGH vulnerabilities (threshold: %d)",
severityCounts["HIGH"], thresholds.High), nil
}
// Check medium
if thresholds.Medium >= 0 && severityCounts["MEDIUM"] > thresholds.Medium {
return true, fmt.Sprintf("Package has %d MEDIUM vulnerabilities (threshold: %d)",
severityCounts["MEDIUM"], thresholds.Medium), nil
}
// Check low
if thresholds.Low >= 0 && severityCounts["LOW"] > thresholds.Low {
return true, fmt.Sprintf("Package has %d LOW vulnerabilities (threshold: %d)",
severityCounts["LOW"], thresholds.Low), nil
}
// Check block on severity
if m.config.BlockOnSeverity != "" && m.config.BlockOnSeverity != "none" {
severity := strings.ToUpper(m.config.BlockOnSeverity)
// Block if any vulnerabilities at or above the specified severity exist
switch severity {
case "CRITICAL":
if severityCounts["CRITICAL"] > 0 {
return true, fmt.Sprintf("Package has CRITICAL vulnerabilities"), nil
}
case "HIGH":
if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 {
return true, fmt.Sprintf("Package has HIGH or CRITICAL vulnerabilities"), nil
}
case "MEDIUM":
if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 || severityCounts["MEDIUM"] > 0 {
return true, fmt.Sprintf("Package has MEDIUM, HIGH, or CRITICAL vulnerabilities"), nil
}
case "LOW":
if len(result.Vulnerabilities) > 0 {
return true, fmt.Sprintf("Package has vulnerabilities"), nil
}
}
}
return false, "", nil
}
// UpdateDatabases updates vulnerability databases for all scanners
func (m *Manager) UpdateDatabases(ctx context.Context) error {
if !m.enabled {
return nil
}
log.Info().Msg("Updating vulnerability databases")
for _, scanner := range m.scanners {
if updater, ok := scanner.(DatabaseUpdater); ok {
if err := updater.UpdateDatabase(ctx); err != nil {
log.Error().
Err(err).
Str("scanner", scanner.Name()).
Msg("Failed to update database")
return err
}
}
}
log.Info().Msg("Vulnerability databases updated successfully")
return nil
}
// Health checks health of all scanners
func (m *Manager) Health(ctx context.Context) error {
if !m.enabled {
return nil
}
for _, scanner := range m.scanners {
if err := scanner.Health(ctx); err != nil {
return fmt.Errorf("scanner %s health check failed: %w", scanner.Name(), err)
}
}
return nil
}
+240
View File
@@ -0,0 +1,240 @@
package trivy
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"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"
)
// ScannerName is the name of this scanner
const ScannerName = "trivy"
// Scanner implements the Scanner interface using Trivy
type Scanner struct {
config config.TrivyConfig
}
// TrivyResult represents Trivy JSON output structure
type TrivyResult struct {
SchemaVersion int `json:"SchemaVersion"`
ArtifactName string `json:"ArtifactName"`
ArtifactType string `json:"ArtifactType"`
Metadata TrivyMetadata `json:"Metadata"`
Results []TrivyVulnResult `json:"Results"`
}
type TrivyMetadata struct {
OS *TrivyOS `json:"OS,omitempty"`
RepoTags []string `json:"RepoTags,omitempty"`
RepoDigests []string `json:"RepoDigests,omitempty"`
ImageConfig *TrivyImageConfig `json:"ImageConfig,omitempty"`
}
type TrivyOS struct {
Family string `json:"Family"`
Name string `json:"Name"`
}
type TrivyImageConfig struct {
Architecture string `json:"architecture"`
Created string `json:"created"`
}
type TrivyVulnResult struct {
Target string `json:"Target"`
Class string `json:"Class"`
Type string `json:"Type"`
Vulnerabilities []TrivyVulnerability `json:"Vulnerabilities"`
}
type TrivyVulnerability struct {
VulnerabilityID string `json:"VulnerabilityID"`
PkgName string `json:"PkgName"`
InstalledVersion string `json:"InstalledVersion"`
FixedVersion string `json:"FixedVersion"`
Severity string `json:"Severity"`
Title string `json:"Title"`
Description string `json:"Description"`
References []string `json:"References"`
PrimaryURL string `json:"PrimaryURL"`
}
// New creates a new Trivy scanner
func New(cfg config.TrivyConfig) *Scanner {
return &Scanner{
config: cfg,
}
}
// Name returns the scanner name
func (s *Scanner) Name() string {
return ScannerName
}
// UpdateDatabase updates Trivy's vulnerability database
func (s *Scanner) UpdateDatabase(ctx context.Context) error {
log.Info().Msg("Updating Trivy vulnerability database")
cmd := exec.CommandContext(ctx, "trivy", "image", "--download-db-only")
if s.config.CacheDB != "" {
cmd.Env = append(os.Environ(), fmt.Sprintf("TRIVY_CACHE_DIR=%s", s.config.CacheDB))
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to update Trivy database: %w (output: %s)", err, string(output))
}
log.Info().Msg("Trivy vulnerability database updated successfully")
return nil
}
// Scan scans a package for vulnerabilities using Trivy
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
// Set timeout
if s.config.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, s.config.Timeout)
defer cancel()
}
// Determine scan type based on registry
scanType := s.determineScanType(registry, filePath)
// Build Trivy command
args := []string{
scanType,
"--format", "json",
"--quiet",
filePath,
}
cmd := exec.CommandContext(ctx, "trivy", args...)
// Set cache directory if configured
if s.config.CacheDB != "" {
cmd.Env = append(os.Environ(), fmt.Sprintf("TRIVY_CACHE_DIR=%s", s.config.CacheDB))
}
// Execute scan
output, err := cmd.Output()
if err != nil {
// Check if it's a timeout
if ctx.Err() == context.DeadlineExceeded {
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: s.Name(),
ScannedAt: time.Now(),
Status: metadata.ScanStatusError,
Details: map[string]interface{}{
"error": "scan timeout",
},
}, nil
}
return nil, fmt.Errorf("trivy scan failed: %w", err)
}
// Parse Trivy output
var trivyResult TrivyResult
if err := json.Unmarshal(output, &trivyResult); err != nil {
return nil, fmt.Errorf("failed to parse Trivy output: %w", err)
}
// Convert to metadata.ScanResult
return s.convertTrivyResult(&trivyResult, registry, packageName, version), nil
}
// determineScanType determines the appropriate Trivy scan type
func (s *Scanner) determineScanType(registry, filePath string) string {
// For now, use filesystem scan for packages
// Container image scanning would need different handling
ext := strings.ToLower(filePath[strings.LastIndex(filePath, ".")+1:])
switch registry {
case "npm":
return "fs" // Filesystem scan for npm packages
case "pypi":
return "fs" // Filesystem scan for Python packages
case "go":
return "fs" // Filesystem scan for Go modules
default:
// Check file extension
if ext == "tar" || ext == "tgz" || ext == "gz" {
return "fs"
}
return "fs"
}
}
// convertTrivyResult converts Trivy result to metadata.ScanResult
func (s *Scanner) convertTrivyResult(trivyResult *TrivyResult, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0)
severityCounts := make(map[string]int)
// Aggregate all vulnerabilities from all results
for _, result := range trivyResult.Results {
for _, vuln := range result.Vulnerabilities {
// Count by severity
severityCounts[strings.ToUpper(vuln.Severity)]++
// Add to vulnerabilities list
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
ID: vuln.VulnerabilityID,
Severity: strings.ToUpper(vuln.Severity),
Title: vuln.Title,
Description: vuln.Description,
References: vuln.References,
FixedIn: vuln.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{}{
"artifact_name": trivyResult.ArtifactName,
"artifact_type": trivyResult.ArtifactType,
"severity_counts": severityCounts,
},
}
}
// Health checks if Trivy is available and working
func (s *Scanner) Health(ctx context.Context) error {
// Check if trivy command exists
cmd := exec.CommandContext(ctx, "trivy", "--version")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("trivy not available: %w (output: %s)", err, string(output))
}
log.Debug().Str("version", strings.TrimSpace(string(output))).Msg("Trivy health check passed")
return nil
}