mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 07:39:19 -05:00
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.
327 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|