Compare commits

..
8 Commits
Author SHA1 Message Date
lukaszraczylo 38edd735b6 fix: improve download statistics tracking resilience
Problem:
- Download counts were not incrementing when packages existed in storage
  but not in the database (e.g., after database migration/reset)
- UpdateDownloadCount() was failing silently when package didn't exist in DB
- Error was completely ignored despite comment claiming "error logged"

Changes:
1. Log errors when UpdateDownloadCount() fails instead of silently ignoring
2. Auto-save package to database if UpdateDownloadCount() fails
3. Retry download count update after saving package
4. Provide detailed error logging for troubleshooting

This fixes the issue where:
- Database is migrated but storage still has cached packages
- Cache hits occur but download events aren't recorded
- Statistics show zero downloads despite actual usage

Resolves user report: "I cleaned go mod which redownloaded the modules
through the proxy but counters did not increased"
2026-01-04 13:44:36 +00:00
lukaszraczylo c207aa72e9 fix: accept GitHub API rate limits in GHSA health check
- Rate limits (403) are now accepted as healthy
- Rate limiting is expected without a GitHub token
- Only real errors (network failures, 500s) fail the health check
- Prevents health check failures due to unauthenticated API usage

Related: GHSA scanner health checks
2026-01-04 13:33:49 +00:00
lukaszraczylo bf0925a4fc fix: return degraded status for scanner health check failures
- Scanner failures (e.g., GitHub API rate limits) no longer mark server as unhealthy
- Server can still serve cached packages when scanners are unavailable
- Readiness probes will now pass with degraded scanner status
- Prevents unnecessary pod restarts due to external API issues

Fixes: Readiness probes failing with 503 due to GHSA rate limiting
2026-01-04 13:33:10 +00:00
lukaszraczylo bc854aa183 perf: remove nginx rate limiting for internal package proxy
- Removed rate limiting zones (10r/s for API, 5r/s for downloads)
- Internal package proxy doesn't need rate limiting
- During builds, package managers make hundreds of concurrent requests
- Rate limiting was causing unnecessary delays and potential 429 errors

This improves build performance significantly for CI/CD pipelines
2026-01-04 13:09:18 +00:00
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
lukaszraczylo 8848656193 fix: preserve /api prefix in nginx reverse proxy
- Changed proxy_pass from http://backend/ to http://backend
- Trailing slash was stripping /api prefix causing 404 errors
- Backend expects /api/stats not /stats
2026-01-04 03:55:40 +00:00
lukaszraczylo 3ecff61114 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 03:18:49 +00:00
lukaszraczylo f86943b884 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 02:48:28 +00:00
8 changed files with 130 additions and 23 deletions
+2 -11
View File
@@ -44,10 +44,6 @@ upstream backend {
keepalive 32;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=download_limit:10m rate=5r/s;
# Cache configuration
proxy_cache_path /var/cache/nginx/static levels=1:2 keys_zone=static_cache:10m max_size=100m inactive=60m use_temp_path=off;
@@ -77,11 +73,8 @@ server {
# API endpoints - proxy to backend
location /api/ {
# Rate limiting
limit_req zone=api_limit burst=20 nodelay;
# Proxy settings
proxy_pass http://backend/;
proxy_pass http://backend;
proxy_http_version 1.1;
# Headers
@@ -120,10 +113,8 @@ server {
proxy_set_header Connection "";
}
# Package download endpoints with rate limiting
# Package download endpoints
location ~ ^/(npm|pypi|go)/ {
limit_req zone=download_limit burst=10 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
+11
View File
@@ -16,6 +16,10 @@ RUN apk add --no-cache \
bash \
sqlite-libs \
musl \
python3 \
py3-pip \
npm \
go \
&& update-ca-certificates
# Install Trivy for container scanning
@@ -24,6 +28,13 @@ RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/
# Install Grype for vulnerability scanning
RUN curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Install govulncheck for Go vulnerability scanning
RUN go install golang.org/x/vuln/cmd/govulncheck@latest && \
mv /root/go/bin/govulncheck /usr/local/bin/
# Install pip-audit for Python package vulnerability scanning
RUN pip3 install --no-cache-dir pip-audit --break-system-packages
# Create non-root user
RUN addgroup -g 1000 scanner && \
adduser -D -u 1000 -G scanner scanner
+11 -5
View File
@@ -93,24 +93,30 @@ data:
low: {{ .Values.security.blockThresholds.low }}
scanners:
trivy:
enabled: {{ .Values.security.scanners.trivy.enabled }}
# Disabled in server config (no trivy binary), enabled via env var in scanner pod
enabled: false
timeout: {{ .Values.security.scanners.trivy.timeout | quote }}
cache_db: {{ .Values.security.scanners.trivy.cacheDb | quote }}
osv:
# API-based scanner - works in both server and scanner pods
enabled: {{ .Values.security.scanners.osv.enabled }}
api_url: {{ .Values.security.scanners.osv.apiUrl | quote }}
timeout: {{ .Values.security.scanners.osv.timeout | quote }}
grype:
enabled: {{ .Values.security.scanners.grype.enabled }}
# Disabled in server config (no grype binary), enabled via env var in scanner pod
enabled: false
timeout: {{ .Values.security.scanners.grype.timeout | quote }}
govulncheck:
enabled: {{ .Values.security.scanners.govulncheck.enabled }}
# Disabled in server config (no go/govulncheck binary), enabled via env var in scanner pod
enabled: false
timeout: {{ .Values.security.scanners.govulncheck.timeout | quote }}
npm_audit:
enabled: {{ .Values.security.scanners.npmAudit.enabled }}
# Disabled in server config (no npm binary), enabled via env var in scanner pod
enabled: false
timeout: {{ .Values.security.scanners.npmAudit.timeout | quote }}
pip_audit:
enabled: {{ .Values.security.scanners.pipAudit.enabled }}
# Disabled in server config (no pip binary), enabled via env var in scanner pod
enabled: false
timeout: {{ .Values.security.scanners.pipAudit.timeout | quote }}
ghsa:
enabled: {{ .Values.security.scanners.ghsa.enabled }}
@@ -109,6 +109,17 @@ spec:
env:
- name: CONFIG_FILE
value: /etc/gohoarder/config.yaml
# Enable tool-based scanners only in scanner pod (server doesn't have the tools)
- name: GOHOARDER_SECURITY_SCANNERS_TRIVY_ENABLED
value: "{{ .Values.security.scanners.trivy.enabled }}"
- name: GOHOARDER_SECURITY_SCANNERS_GRYPE_ENABLED
value: "{{ .Values.security.scanners.grype.enabled }}"
- name: GOHOARDER_SECURITY_SCANNERS_GOVULNCHECK_ENABLED
value: "{{ .Values.security.scanners.govulncheck.enabled }}"
- name: GOHOARDER_SECURITY_SCANNERS_NPM_AUDIT_ENABLED
value: "{{ .Values.security.scanners.npmAudit.enabled }}"
- name: GOHOARDER_SECURITY_SCANNERS_PIP_AUDIT_ENABLED
value: "{{ .Values.security.scanners.pipAudit.enabled }}"
{{- if and (eq .Values.metadata.backend "postgresql") .Values.metadata.postgresql.existingSecret }}
- name: POSTGRES_USER
valueFrom:
+3 -1
View File
@@ -290,7 +290,9 @@ func (a *App) initializeComponents() error {
a.healthChecker.AddCheck("scanner", func(ctx context.Context) (health.Status, string) {
if a.config.Security.Enabled {
if err := a.scanManager.Health(ctx); err != nil {
return health.StatusUnhealthy, err.Error()
// Scanner failures (e.g., API rate limits) shouldn't mark server as unhealthy
// Server can still serve cached packages, just can't scan new ones
return health.StatusDegraded, err.Error()
}
}
return health.StatusHealthy, ""
+31 -1
View File
@@ -145,7 +145,37 @@ func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string
if err == nil {
// Cache hit!
metrics.RecordCacheHit(registry)
_ = m.metadata.UpdateDownloadCount(ctx, registry, name, version) // #nosec G104 -- Async update, error logged
// Update download count (log errors for debugging)
if err := m.metadata.UpdateDownloadCount(ctx, registry, name, version); err != nil {
log.Warn().
Err(err).
Str("registry", registry).
Str("package", name).
Str("version", version).
Msg("Failed to update download count - package may not exist in database")
// Try to save package to database if it doesn't exist
// This handles the case where storage has files but database was migrated/reset
if saveErr := m.metadata.SavePackage(ctx, pkg); saveErr != nil {
log.Error().
Err(saveErr).
Str("registry", registry).
Str("package", name).
Str("version", version).
Msg("Failed to save package to database")
} else {
// Retry download count update after saving package
if retryErr := m.metadata.UpdateDownloadCount(ctx, registry, name, version); retryErr != nil {
log.Error().
Err(retryErr).
Str("registry", registry).
Str("package", name).
Str("version", version).
Msg("Failed to update download count even after saving package")
}
}
}
// Track download in analytics if enabled
if m.analytics != nil {
+9 -3
View File
@@ -107,11 +107,17 @@ func (s *Scanner) Health(ctx context.Context) error {
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("github api returned status: %d", resp.StatusCode)
// Accept any 2xx or 403 (rate limit) as healthy
// Rate limits are expected without a GitHub token and shouldn't fail health checks
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode == http.StatusForbidden {
log.Debug().Msg("GitHub API rate limited (expected without token)")
return nil
}
return nil
return fmt.Errorf("github api returned status: %d", resp.StatusCode)
}
// mapRegistryToEcosystem maps our registry names to GitHub ecosystem names
+52 -2
View File
@@ -121,13 +121,17 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
// 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: packageName,
Name: cleanName,
Ecosystem: ecosystem,
},
Version: version,
Version: cleanVersion,
}
// Marshal request
@@ -199,6 +203,52 @@ func (s *Scanner) registryToEcosystem(registry string) string {
}
}
// 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))