Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-09-17 00:02:04 -04:00
pkg-proxy/internal/scanner/group_test.go
aarnaud 5499837084
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298)
Runs fetched artifacts through pluggable external scanners after they're
staged in storage but before they're committed to the cache DB, so a
block verdict deletes the object instead of ever exposing it to a
client. Scanners pull the staged bytes themselves via a short-lived
HMAC-signed internal route rather than the proxy pushing bytes to them,
keeping the mechanism storage-backend-agnostic and avoiding uploading
potentially huge artifacts through the proxy's own egress.

Hardening baked in from the start: the internal scan-fetch route is
gated both at router-mount time and in the handler so it's inert
whenever scanning is disabled or unsigned; the signing key is mandatory
whenever scanning is enabled, enforced directly in scanner.NewGroup
rather than relying on callers to invoke config validation; the scan
call and the delete-on-block cleanup both run on a context detached
from the client's, so a client disconnecting mid-scan can't be mistaken
for a scanner failure, doesn't cause a legitimate artifact to be
deleted, and doesn't leave a genuinely blocked artifact's bytes
orphaned in storage; and scanner infrastructure errors (connection
failures, internal hostnames) are never forwarded verbatim to anonymous
clients, only a generic message. The scan-error metric also correctly
distinguishes a scanner's own timeout from being cancelled because a
sibling scanner already decided the verdict.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 10:06:44 +01:00

245 lines
7.3 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_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")
}
}