Files
gohoarder/pkg/auth/validator.go
T
lukaszraczylo 171386f7a7 fix: remediate severity-ordered review findings across cache, scanners, and proxies
- H2: gormstore soft-delete + storage_key UNIQUE collision; re-save after eviction succeeds
- H1: cache getOrFetch cache-hit branch no longer runs CheckVulnerabilities on metadata entries
- M2: cache store() streams upstream body to temp file + SHA256 instead of double-buffering
- H3/L3: FetchResult/FetchFunc plumbing (+RequiresAuth/AuthProvider); proxy closures adapted;
        goproxy strips git+ prefix from UpstreamURL
- H4: scanner normalizes bypass package names; CheckVulnerabilities builds normalized packageKey
- M4: requireAdmin routes through extractAPIKey -> ValidateAPIKey -> admin role check
- M5: network client keeps only final RecordFailure (one count per attempt cycle)
- M3: normalizeOriginHost + buildCheckOrigin rewrite; origin_test.go added
- M1: app lifecycle cancel func; Run -> Shutdown on listen error (sole cache owner); JoinHostPort
- M6: VulnerabilityThresholds defaults all -1 so BlockOnSeverity governs
- L4: metadata.NormalizeSeverity at 4 severity-count sites (MEDIUM->MODERATE consistency)
- L1: handleDeleteBypass uses structured *errors.Error + ErrCodeNotFound
- L2: pypi extractVersionFromFilename (wheel/2nd segment, non-wheel/last digit segment);
      cacheVersion uses full unique basename; regression tests added
- L5: document per-instance ValidationCache key scoping; rename packageURL param to resource

All: go build ./... && go vet ./... && go test ./... pass; golangci-lint clean.
2026-09-04 14:24:13 +01:00

318 lines
9.8 KiB
Go

package auth
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/rs/zerolog/log"
)
// CredentialValidator validates credentials with upstream registries
type CredentialValidator interface {
// ValidateAccess checks if credentials grant access to a package
// Returns (allowed bool, error)
ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error)
// Provider returns the upstream auth provider identifier stored on
// packages that require auth (e.g. github, gitlab, npm, pypi).
Provider() string
}
// NPMValidator validates npm registry credentials
type NPMValidator struct {
client *http.Client
timeout time.Duration
}
// NewNPMValidator creates a new npm credential validator
func NewNPMValidator() *NPMValidator {
return &NPMValidator{
client: &http.Client{
Timeout: 5 * time.Second,
},
timeout: 5 * time.Second,
}
}
// Provider identifies this validator's upstream provider.
func (v *NPMValidator) Provider() string {
return "npm"
}
// ValidateAccess validates npm package access using HEAD request
func (v *NPMValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil)
if err != nil {
return false, err
}
// Add credentials if provided
if credentials != "" {
req.Header.Set("Authorization", credentials)
}
resp, err := v.client.Do(req)
if err != nil {
// Network error - fail closed. Caller decides any fallback policy.
log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, denying access")
return false, fmt.Errorf("validation failed: %w", err)
}
defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Check status code
switch resp.StatusCode {
case 200, 304:
// Access granted
return true, nil
case 401, 403, 404:
// Access denied
return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode)
default:
// Unexpected status - fail closed.
log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, denying access")
return false, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
}
// PyPIValidator validates PyPI registry credentials
type PyPIValidator struct {
client *http.Client
timeout time.Duration
}
// NewPyPIValidator creates a new PyPI credential validator
func NewPyPIValidator() *PyPIValidator {
return &PyPIValidator{
client: &http.Client{
Timeout: 5 * time.Second,
},
timeout: 5 * time.Second,
}
}
// Provider identifies this validator's upstream provider.
func (v *PyPIValidator) Provider() string {
return "pypi"
}
// ValidateAccess validates PyPI package access using HEAD request
func (v *PyPIValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil)
if err != nil {
return false, err
}
// Add credentials if provided
if credentials != "" {
req.Header.Set("Authorization", credentials)
}
resp, err := v.client.Do(req)
if err != nil {
// Network error - fail closed. Caller decides any fallback policy.
log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, denying access")
return false, fmt.Errorf("validation failed: %w", err)
}
defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Check status code
switch resp.StatusCode {
case 200, 304:
// Access granted
return true, nil
case 401, 403, 404:
// Access denied
return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode)
default:
// Unexpected status - fail closed.
log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, denying access")
return false, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
}
// GoValidator validates Go module credentials
type GoValidator struct {
timeout time.Duration
}
// NewGoValidator creates a new Go module credential validator
func NewGoValidator() *GoValidator {
return &GoValidator{
timeout: 10 * time.Second,
}
}
// Provider returns the base upstream provider identifier used when the
// registry does not distinguish hosts (e.g. a generic git host).
func (v *GoValidator) Provider() string {
return "git"
}
// ProviderFor derives the specific provider identifier from a module path.
// Mirrors the host branching in ValidateAccess so the stored AuthProvider
// reflects the actual upstream host (github, gitlab, or a generic git host).
func (v *GoValidator) ProviderFor(modulePath string) string {
switch {
case strings.HasPrefix(modulePath, "github.com/"):
return "github"
case strings.HasPrefix(modulePath, "gitlab.com/"):
return "gitlab"
default:
return "git"
}
}
// ValidateAccess validates Go module access using git ls-remote
func (v *GoValidator) ValidateAccess(ctx context.Context, modulePath string, credentials string) (bool, error) {
// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, v.timeout)
defer cancel()
// Determine repository type and validate accordingly
if strings.HasPrefix(modulePath, "github.com/") {
return v.validateGitHub(ctx, modulePath, credentials)
}
if strings.HasPrefix(modulePath, "gitlab.com/") {
return v.validateGitLab(ctx, modulePath, credentials)
}
// For other Git providers, use generic git validation
return v.validateGit(ctx, modulePath, credentials)
}
func (v *GoValidator) validateGitHub(ctx context.Context, modulePath, credentials string) (bool, error) {
// Extract token from credentials
token := strings.TrimPrefix(credentials, "Bearer ")
token = strings.TrimPrefix(token, "Token ")
if token == "" || token == credentials {
// No token provided or not in expected format
return false, fmt.Errorf("no GitHub token provided")
}
// Build git URL
repoURL := fmt.Sprintf("https://%s.git", modulePath)
// Create temporary directory for .netrc
tempDir, err := os.MkdirTemp("", "gohoarder-validate-*")
if err != nil {
return false, err
}
defer func() { _ = os.RemoveAll(tempDir) }()
// Create .netrc file with credentials
netrcPath := filepath.Join(tempDir, ".netrc")
netrcContent := fmt.Sprintf("machine github.com\nlogin oauth2\npassword %s\n", token)
if writeErr := os.WriteFile(netrcPath, []byte(netrcContent), 0600); writeErr != nil {
return false, writeErr
}
// Run git ls-remote (lightweight, just checks access)
cmd := exec.CommandContext(ctx, "git", "ls-remote", repoURL, "HEAD") // #nosec G204 -- git command with validated URL
cmd.Env = append(os.Environ(),
"HOME="+tempDir, // Use temp .netrc
"GIT_TERMINAL_PROMPT=0", // Disable prompts
)
output, err := cmd.CombinedOutput()
if err != nil {
// Check error message
errMsg := string(output)
if strings.Contains(errMsg, "could not read Username") ||
strings.Contains(errMsg, "Authentication failed") ||
strings.Contains(errMsg, "fatal: repository") ||
strings.Contains(errMsg, "not found") {
// Access denied
return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg))
}
// Other error (network, etc.) - fail closed.
log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, denying access")
return false, fmt.Errorf("validation error: %w", err)
}
// Success - repository accessible
return true, nil
}
func (v *GoValidator) validateGitLab(ctx context.Context, modulePath, credentials string) (bool, error) {
// Extract token from credentials
token := strings.TrimPrefix(credentials, "Bearer ")
token = strings.TrimPrefix(token, "Token ")
token = strings.TrimPrefix(token, "Private-Token ")
if token == "" || token == credentials {
// No token provided
return false, fmt.Errorf("no GitLab token provided")
}
// Build git URL
repoURL := fmt.Sprintf("https://%s.git", modulePath)
// Create temporary directory for .netrc
tempDir, err := os.MkdirTemp("", "gohoarder-validate-*")
if err != nil {
return false, err
}
defer func() { _ = os.RemoveAll(tempDir) }()
// Create .netrc file with credentials
netrcPath := filepath.Join(tempDir, ".netrc")
netrcContent := fmt.Sprintf("machine gitlab.com\nlogin oauth2\npassword %s\n", token)
if writeErr := os.WriteFile(netrcPath, []byte(netrcContent), 0600); writeErr != nil {
return false, writeErr
}
// Run git ls-remote
cmd := exec.CommandContext(ctx, "git", "ls-remote", repoURL, "HEAD") // #nosec G204 -- git command with validated URL
cmd.Env = append(os.Environ(),
"HOME="+tempDir,
"GIT_TERMINAL_PROMPT=0",
)
output, err := cmd.CombinedOutput()
if err != nil {
errMsg := string(output)
if strings.Contains(errMsg, "could not read Username") ||
strings.Contains(errMsg, "Authentication failed") ||
strings.Contains(errMsg, "not found") {
return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg))
}
log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, denying access")
return false, fmt.Errorf("validation error: %w", err)
}
return true, nil
}
func (v *GoValidator) validateGit(ctx context.Context, modulePath, credentials string) (bool, error) {
// Generic git validation for other providers
// Similar to GitHub validation but with generic host detection
repoURL := fmt.Sprintf("https://%s.git", modulePath)
cmd := exec.CommandContext(ctx, "git", "ls-remote", repoURL, "HEAD") // #nosec G204 -- git command with validated URL
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
output, err := cmd.CombinedOutput()
if err != nil {
errMsg := string(output)
if strings.Contains(errMsg, "could not read Username") ||
strings.Contains(errMsg, "Authentication failed") ||
strings.Contains(errMsg, "not found") {
return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg))
}
log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, denying access")
return false, fmt.Errorf("validation error: %w", err)
}
return true, nil
}