Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/scanner/group_test.go
Simon Chrzanowski f4d074d7c4 Normalise ecosystems the scanner group is asked about
applicable compared the caller's ecosystem raw against the normalised
configured keys, so a caller passing an alias such as go or rubygems
skipped a scanner restricted to golang or gem. Normalise it first.

NewGroup now also rejects unknown names the way Validate does, through
a shared ScannerEcosystemKey, instead of building a scanner that can
never match.
2026-11-08 10:08:52 +01:00

327 lines
9.9 KiB
Go

package scanner
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/prometheus/client_golang/prometheus/testutil"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// fakeScanner is a Scanner test double that can simulate a delay, a fixed
// result or error, and report whether its context was cancelled before it
// returned.
type fakeScanner struct {
name string
delay time.Duration
result Result
err error
cancelled *bool
}
func (f *fakeScanner) Name() string { return f.name }
func (f *fakeScanner) Scan(ctx context.Context, _ Request) (Result, error) {
select {
case <-time.After(f.delay):
case <-ctx.Done():
if f.cancelled != nil {
*f.cancelled = true
}
return Result{}, ctx.Err()
}
return f.result, f.err
}
func newTestGroup(entries []entry, failOpen bool) *Group {
return &Group{
entries: entries,
timeout: time.Second,
failOpen: failOpen,
logger: discardLogger(),
}
}
func TestGroup_Enabled(t *testing.T) {
disabled, err := NewGroup(config.ScanningConfig{Enabled: false}, discardLogger())
if err != nil {
t.Fatalf("NewGroup() error: %v", err)
}
if disabled.Enabled() {
t.Error("Enabled() = true for disabled config, want false")
}
enabled, err := NewGroup(config.ScanningConfig{
Enabled: true,
Timeout: "10s",
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "clamav", URL: "http://clamav.invalid", Mode: "block"},
},
}, discardLogger())
if err != nil {
t.Fatalf("NewGroup() error: %v", err)
}
if !enabled.Enabled() {
t.Error("Enabled() = false for configured scanner, want true")
}
}
func TestGroup_Covers(t *testing.T) {
var nilGroup *Group
if nilGroup.Covers("npm") {
t.Error("nil group Covers(npm) = true, want false")
}
g, err := NewGroup(config.ScanningConfig{
Enabled: true,
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "trivy", URL: "http://trivy.invalid", Mode: "monitor", Ecosystems: []string{"npm"}},
{Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"pypi"}},
},
}, discardLogger())
if err != nil {
t.Fatalf("NewGroup() error: %v", err)
}
for ecosystem, want := range map[string]bool{"npm": true, "pypi": true, "freebsd": false} {
if got := g.Covers(ecosystem); got != want {
t.Errorf("Covers(%s) = %v, want %v", ecosystem, got, want)
}
}
all := newTestGroup([]entry{{mode: modeBlock}}, false)
if !all.Covers("freebsd") {
t.Error("unrestricted scanner: Covers(freebsd) = false, want true")
}
}
func TestGroup_NewGroup_InvalidMode(t *testing.T) {
_, err := NewGroup(config.ScanningConfig{
Enabled: true,
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "bad", URL: "http://bad.invalid", Mode: "quarantine"},
},
}, discardLogger())
if err == nil {
t.Fatal("NewGroup() error = nil, want error for invalid mode")
}
}
func TestGroup_NewGroup_MissingSigningKey(t *testing.T) {
_, err := NewGroup(config.ScanningConfig{
Enabled: true,
Scanners: []config.ScannerConfig{
{Name: "clamav", URL: "http://clamav.invalid", Mode: "block"},
},
}, discardLogger())
if err == nil {
t.Fatal("NewGroup() error = nil, want error for missing signing key")
}
}
func TestGroup_Scan_NoApplicableScanners(t *testing.T) {
g := newTestGroup([]entry{
{
scanner: &fakeScanner{name: "npm-only", result: Result{Allowed: false}},
mode: modeBlock,
ecosystems: map[string]struct{}{"npm": {}},
},
}, false)
result := g.Scan(context.Background(), Request{Ecosystem: "pypi"})
if !result.Allowed {
t.Error("Allowed = false, want true when no scanner applies to the ecosystem")
}
}
func TestGroup_Scan_Allowed(t *testing.T) {
g := newTestGroup([]entry{
{scanner: &fakeScanner{name: "clamav", result: Result{Allowed: true}}, mode: modeBlock},
}, false)
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
if !result.Allowed {
t.Error("Allowed = false, want true")
}
}
func TestGroup_Scan_Blocked(t *testing.T) {
g := newTestGroup([]entry{
{scanner: &fakeScanner{name: "clamav", result: Result{Allowed: false, Reason: "malware"}}, mode: modeBlock},
}, false)
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
if result.Allowed {
t.Error("Allowed = true, want false")
}
if result.Reason != "malware" {
t.Errorf("Reason = %q, want %q", result.Reason, "malware")
}
if result.ScannerName != "clamav" {
t.Errorf("ScannerName = %q, want %q", result.ScannerName, "clamav")
}
if result.InfraError {
t.Error("InfraError = true, want false — this is a genuine scanner verdict, not an infrastructure failure")
}
}
func TestGroup_Scan_MonitorNeverBlocks(t *testing.T) {
g := newTestGroup([]entry{
{
scanner: &fakeScanner{name: "trivy", result: Result{
Allowed: false,
Reason: "cve found",
Findings: []Finding{{Severity: "medium", Title: "CVE-1234"}},
}},
mode: modeMonitor,
},
}, false)
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
if !result.Allowed {
t.Error("Allowed = false, want true — monitor mode must never block")
}
if len(result.Findings) != 1 || result.Findings[0].Title != "CVE-1234" {
t.Errorf("Findings = %+v, want the monitor scanner's finding folded in", result.Findings)
}
}
func TestGroup_Scan_FirstBlockCancelsOthers(t *testing.T) {
var slowSawCancel bool
g := newTestGroup([]entry{
{scanner: &fakeScanner{name: "fast-block", result: Result{Allowed: false, Reason: "blocked"}}, mode: modeBlock},
{scanner: &fakeScanner{name: "slow", delay: 2 * time.Second, cancelled: &slowSawCancel}, mode: modeBlock},
}, false)
start := time.Now()
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
elapsed := time.Since(start)
if result.Allowed {
t.Error("Allowed = true, want false")
}
if elapsed >= 2*time.Second {
t.Errorf("Scan() took %v, want it to return promptly once the slow scanner's context was cancelled", elapsed)
}
if !slowSawCancel {
t.Error("slow scanner never observed context cancellation")
}
if got := testutil.ToFloat64(metrics.ScanErrors.WithLabelValues("npm", "slow", "cancelled")); got != 1 {
t.Errorf("scan_errors{error_type=cancelled} = %v, want 1 — the slow scanner was aborted by a sibling's block, not by its own timeout", got)
}
if got := testutil.ToFloat64(metrics.ScanErrors.WithLabelValues("npm", "slow", "timeout")); got != 0 {
t.Errorf("scan_errors{error_type=timeout} = %v, want 0 — intra-group cancellation must not be mislabelled as a timeout", got)
}
}
func TestGroup_Scan_WaitsForAllBlockScannersBeforeAllowing(t *testing.T) {
g := newTestGroup([]entry{
{scanner: &fakeScanner{name: "fast", result: Result{Allowed: true}}, mode: modeBlock},
{scanner: &fakeScanner{name: "slow", delay: 30 * time.Millisecond, result: Result{Allowed: true}}, mode: modeBlock},
}, false)
start := time.Now()
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
elapsed := time.Since(start)
if !result.Allowed {
t.Error("Allowed = false, want true")
}
if elapsed < 30*time.Millisecond {
t.Errorf("Scan() returned after %v, want it to wait for the slower block scanner", elapsed)
}
}
func TestGroup_Scan_ErrorFailClosedByDefault(t *testing.T) {
g := newTestGroup([]entry{
{scanner: &fakeScanner{name: "flaky", err: errors.New("connection refused")}, mode: modeBlock},
}, false)
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
if result.Allowed {
t.Error("Allowed = true, want false — default posture is fail-closed on scanner error")
}
if !result.InfraError {
t.Error("InfraError = false, want true — the block came from a scanner call failure, not a verdict")
}
if !strings.Contains(result.Reason, "connection refused") {
t.Errorf("Reason = %q, want it to include the underlying error for server-side logging", result.Reason)
}
}
func TestGroup_Scan_ErrorFailOpen(t *testing.T) {
g := newTestGroup([]entry{
{scanner: &fakeScanner{name: "flaky", err: errors.New("connection refused")}, mode: modeBlock},
}, true)
result := g.Scan(context.Background(), Request{Ecosystem: "npm"})
if !result.Allowed {
t.Error("Allowed = false, want true — FailOpen must treat scanner errors as allowed")
}
}
func TestGroupBlocks(t *testing.T) {
g, err := NewGroup(config.ScanningConfig{
Enabled: true,
SigningKey: "key",
Scanners: []config.ScannerConfig{
{Name: "watch", URL: "http://watch.invalid", Mode: "monitor"},
{Name: "gate", URL: "http://gate.invalid", Mode: "block", Ecosystems: []string{"npm"}},
},
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
if !g.Blocks("npm") {
t.Error("Blocks(npm) = false with a block-mode scanner for npm")
}
if g.Blocks("pypi") {
t.Error("Blocks(pypi) = true with only a monitor-mode scanner for it")
}
var none *Group
if none.Blocks("npm") {
t.Error("nil group blocks")
}
}
func TestGroup_NormalisesEcosystemAliases(t *testing.T) {
g, err := NewGroup(config.ScanningConfig{
Enabled: true,
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"go", "docker", "rubygems"}},
},
}, discardLogger())
if err != nil {
t.Fatalf("NewGroup() error: %v", err)
}
for _, ecosystem := range []string{"golang", "oci", "gem", "go", "Docker", "rubygems"} {
if !g.Blocks(ecosystem) {
t.Errorf("Blocks(%s) = false, want true", ecosystem)
}
}
}
func TestNewGroup_RejectsUnknownEcosystem(t *testing.T) {
_, err := NewGroup(config.ScanningConfig{
Enabled: true,
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"npm", "pypy"}},
},
}, discardLogger())
if err == nil || !strings.Contains(err.Error(), "pypy") {
t.Errorf("NewGroup() error = %v, want unknown ecosystem pypy", err)
}
}