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).
623 lines
19 KiB
Go
623 lines
19 KiB
Go
// Package scanner orchestrates pluggable vulnerability scanners and
|
|
// records their results against cached packages.
|
|
package scanner
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
|
hoardererrors "github.com/lukaszraczylo/gohoarder/pkg/errors"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/events"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/grype"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/npmaudit"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/osv"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
|
|
"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 {
|
|
metadataStore metadata.MetadataStore
|
|
broadcaster events.Broadcaster
|
|
scanners []Scanner
|
|
config config.SecurityConfig
|
|
bcMu sync.RWMutex
|
|
enabled bool
|
|
}
|
|
|
|
// SetBroadcaster wires an events.Broadcaster onto the scanner so scan
|
|
// lifecycle events are published. Pass nil to disable broadcasting.
|
|
// Safe for concurrent use.
|
|
func (m *Manager) SetBroadcaster(b events.Broadcaster) {
|
|
m.bcMu.Lock()
|
|
m.broadcaster = b
|
|
m.bcMu.Unlock()
|
|
}
|
|
|
|
// emit publishes an event via the configured broadcaster, if any.
|
|
// Non-blocking: the underlying transport handles overflow by dropping.
|
|
func (m *Manager) emit(eventType string, payload map[string]interface{}) {
|
|
m.bcMu.RLock()
|
|
b := m.broadcaster
|
|
m.bcMu.RUnlock()
|
|
if b == nil {
|
|
return
|
|
}
|
|
b.BroadcastEvent(eventType, payload)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// Initialize Grype scanner
|
|
if cfg.Scanners.Grype.Enabled {
|
|
grypeScanner := grype.New(cfg.Scanners.Grype)
|
|
manager.RegisterScanner(grypeScanner)
|
|
log.Info().Msg("Grype scanner enabled")
|
|
|
|
// Update database on startup if configured
|
|
if cfg.UpdateDBOnStartup {
|
|
if err := grypeScanner.UpdateDatabase(context.Background()); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to update Grype database on startup")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize govulncheck scanner
|
|
if cfg.Scanners.Govulncheck.Enabled {
|
|
govulncheckScanner := govulncheck.New(cfg.Scanners.Govulncheck)
|
|
manager.RegisterScanner(govulncheckScanner)
|
|
log.Info().Msg("govulncheck scanner enabled")
|
|
}
|
|
|
|
// Initialize npm-audit scanner
|
|
if cfg.Scanners.NpmAudit.Enabled {
|
|
npmAuditScanner := npmaudit.New(cfg.Scanners.NpmAudit)
|
|
manager.RegisterScanner(npmAuditScanner)
|
|
log.Info().Msg("npm-audit scanner enabled")
|
|
}
|
|
|
|
// Initialize pip-audit scanner
|
|
if cfg.Scanners.PipAudit.Enabled {
|
|
pipAuditScanner := pipaudit.New(cfg.Scanners.PipAudit)
|
|
manager.RegisterScanner(pipAuditScanner)
|
|
log.Info().Msg("pip-audit scanner enabled")
|
|
}
|
|
|
|
// Initialize GitHub Advisory Database scanner
|
|
if cfg.Scanners.GHSA.Enabled {
|
|
ghsaScanner := ghsa.New(cfg.Scanners.GHSA)
|
|
manager.RegisterScanner(ghsaScanner)
|
|
log.Info().Msg("GitHub Advisory Database 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 {
|
|
// Skip scanners that don't support this registry
|
|
if !m.shouldRunScanner(scanner.Name(), registry) {
|
|
log.Debug().
|
|
Str("scanner", scanner.Name()).
|
|
Str("registry", registry).
|
|
Msg("Skipping scanner - not compatible with registry")
|
|
continue
|
|
}
|
|
|
|
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, persist a synthetic error result so callers
|
|
// fail closed (no scan == blocked) rather than silently leaving
|
|
// SecurityScanned=false which would allow the package to be served.
|
|
if len(scanResults) == 0 {
|
|
log.Warn().
|
|
Str("package", packageName).
|
|
Msg("All scanners failed, saving scan-error result for fail-closed enforcement")
|
|
|
|
errResult := &metadata.ScanResult{
|
|
ID: uuid.New().String(),
|
|
Registry: registry,
|
|
PackageName: packageName,
|
|
PackageVersion: version,
|
|
Scanner: "all",
|
|
ScannedAt: time.Now(),
|
|
Status: metadata.ScanStatusError,
|
|
VulnerabilityCount: 0,
|
|
Vulnerabilities: []metadata.Vulnerability{},
|
|
Details: map[string]interface{}{
|
|
"error": "all configured scanners failed for this package",
|
|
},
|
|
}
|
|
if err := m.metadataStore.SaveScanResult(ctx, errResult); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("package", packageName).
|
|
Msg("Failed to save scan-error result")
|
|
return err
|
|
}
|
|
m.emit(string(websocket.EventScanComplete), map[string]interface{}{
|
|
"registry": registry,
|
|
"name": packageName,
|
|
"version": version,
|
|
"status": string(errResult.Status),
|
|
"vulnerability_count": errResult.VulnerabilityCount,
|
|
})
|
|
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")
|
|
|
|
m.emit(string(websocket.EventScanComplete), map[string]interface{}{
|
|
"registry": registry,
|
|
"name": packageName,
|
|
"version": version,
|
|
"status": string(mergedResult.Status),
|
|
"vulnerability_count": mergedResult.VulnerabilityCount,
|
|
})
|
|
|
|
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.
|
|
// Order: vulnerable > error > pending > clean. Vulnerable wins because
|
|
// the cache layer must surface the actual vulns. Otherwise, if any
|
|
// scanner errored, propagate so CheckVulnerabilities can fail closed.
|
|
switch result.Status {
|
|
case metadata.ScanStatusVulnerable:
|
|
merged.Status = metadata.ScanStatusVulnerable
|
|
case metadata.ScanStatusError:
|
|
if merged.Status != metadata.ScanStatusVulnerable {
|
|
merged.Status = metadata.ScanStatusError
|
|
}
|
|
case metadata.ScanStatusPending:
|
|
if merged.Status != metadata.ScanStatusVulnerable && merged.Status != metadata.ScanStatusError {
|
|
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,
|
|
"MODERATE": 2,
|
|
"MEDIUM": 2, // Support both for backwards compatibility
|
|
"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.
|
|
// SECURITY: Fail closed when no scan exists or backend errors.
|
|
// Previously this returned (false, "", nil) which allowed unscanned
|
|
// packages through — a fail-open bypass. Cache layer is expected to
|
|
// wait for the initial scan via SecurityScanned flag; once that flag
|
|
// is set, GetScanResult MUST return a record. A missing record at this
|
|
// point indicates either a cleared/lost scan or a transient error;
|
|
// either way we block.
|
|
result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version)
|
|
if err != nil {
|
|
var hErr *hoardererrors.Error
|
|
if stderrors.As(err, &hErr) && hErr.Code == hoardererrors.ErrCodeNotFound {
|
|
return true, "no scan available - fail closed", nil
|
|
}
|
|
// Real backend error (DB transient, etc.) — also block.
|
|
log.Warn().
|
|
Err(err).
|
|
Str("package", packageName).
|
|
Msg("Failed to retrieve scan result, failing closed")
|
|
return true, "scan lookup failed - fail closed", nil
|
|
}
|
|
if result == nil {
|
|
// File-backed metadata store returns (nil, nil) on not-found.
|
|
return true, "no scan available - fail closed", nil
|
|
}
|
|
|
|
// If the scan itself errored (all scanners failed for this package),
|
|
// block. A scan-error record means we don't actually know whether the
|
|
// package is safe.
|
|
if result.Status == metadata.ScanStatusError {
|
|
return true, "scan failed - fail closed", 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 moderate (medium)
|
|
moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"] // Support both for backwards compatibility
|
|
if thresholds.Medium >= 0 && moderateCount > thresholds.Medium {
|
|
return true, fmt.Sprintf("Package has %d MODERATE vulnerabilities (threshold: %d)",
|
|
moderateCount, 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, "Package has CRITICAL vulnerabilities", nil
|
|
}
|
|
case "HIGH":
|
|
if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 {
|
|
return true, "Package has HIGH or CRITICAL vulnerabilities", nil
|
|
}
|
|
case "MODERATE", "MEDIUM":
|
|
moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"]
|
|
if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 || moderateCount > 0 {
|
|
return true, "Package has MODERATE, HIGH, or CRITICAL vulnerabilities", nil
|
|
}
|
|
case "LOW":
|
|
if len(result.Vulnerabilities) > 0 {
|
|
return true, "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
|
|
}
|
|
|
|
// shouldRunScanner determines if a scanner should run for a given registry
|
|
// Language-specific scanners only run for their target ecosystems
|
|
func (m *Manager) shouldRunScanner(scannerName, registry string) bool {
|
|
registry = strings.ToLower(registry)
|
|
|
|
// Language-specific scanners - only run for their target registry
|
|
switch scannerName {
|
|
case "govulncheck":
|
|
return registry == "go"
|
|
case "npm-audit":
|
|
return registry == "npm"
|
|
case "pip-audit":
|
|
return registry == "pypi"
|
|
|
|
// Multi-ecosystem scanners - run for all registries
|
|
case "trivy", "osv", "grype", "github-advisory-database":
|
|
return true
|
|
|
|
// Default: allow scanner to run (for future scanners)
|
|
default:
|
|
return true
|
|
}
|
|
}
|