Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-09-16 07:42:05 -04:00
pkg-proxy/internal/handler/scan_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

310 lines
11 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/scanner"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
)
// newTestScanServer returns an httptest.Server implementing the HTTPScanner
// notify contract, always replying with the given verdict.
func newTestScanServer(t testing.TB, allowed bool, reason string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode scan notify body: %v", err)
}
if body["fetch_url"] == "" || body["fetch_url"] == nil {
t.Error("scan notify body missing fetch_url")
}
_ = json.NewEncoder(w).Encode(map[string]any{"allowed": allowed, "reason": reason})
}))
t.Cleanup(srv.Close)
return srv
}
func newTestScanGroup(t testing.TB, scanURL string, failOpen bool) *scanner.Group {
t.Helper()
g, err := scanner.NewGroup(config.ScanningConfig{
Enabled: true,
FailOpen: failOpen,
Timeout: "15s",
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "test-scanner", URL: scanURL, Mode: "block"},
},
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("scanner.NewGroup() error: %v", err)
}
return g
}
func TestGetOrFetchArtifact_ScanAllowed(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newTestScanGroup(t, newTestScanServer(t, true, "").URL, false)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("clean content")),
ContentType: "application/gzip",
}
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "leftpad", "1.0.0", "leftpad-1.0.0.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
body, _ := io.ReadAll(result.Reader)
if string(body) != "clean content" {
t.Errorf("body = %q, want %q", body, "clean content")
}
cached, err := db.GetCachedArtifact(
purl.MakePURLString("npm", "leftpad", ""), purl.MakePURLString("npm", "leftpad", "1.0.0"), "leftpad-1.0.0.tgz")
if err != nil {
t.Fatalf("GetCachedArtifact() error: %v", err)
}
if cached == nil {
t.Error("expected allowed artifact to be committed to the cache database")
}
if len(store.files) == 0 {
t.Error("expected allowed artifact bytes to remain in storage")
}
}
func TestGetOrFetchArtifact_ScanBlocked(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newTestScanGroup(t, newTestScanServer(t, false, "malware detected").URL, false)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("evil content")),
ContentType: "application/gzip",
}
_, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "evilpkg", "1.0.0", "evilpkg-1.0.0.tgz")
if err == nil {
t.Fatal("expected error for blocked artifact")
}
if !errors.Is(err, ErrArtifactBlocked) {
t.Errorf("error = %v, want wrapped ErrArtifactBlocked", err)
}
if !strings.Contains(err.Error(), "malware detected") {
t.Errorf("error %q does not include scanner reason", err.Error())
}
cached, err := db.GetCachedArtifact(
purl.MakePURLString("npm", "evilpkg", ""), purl.MakePURLString("npm", "evilpkg", "1.0.0"), "evilpkg-1.0.0.tgz")
if err != nil {
t.Fatalf("GetCachedArtifact() error: %v", err)
}
if cached != nil {
t.Error("blocked artifact must never be committed to the cache database")
}
if len(store.files) != 0 {
t.Errorf("blocked artifact bytes must be deleted from storage, got %d files", len(store.files))
}
}
func TestGetOrFetchArtifact_BlockedDeleteSurvivesClientDisconnect(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
const scanDelay = 150 * time.Millisecond
blockingSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(scanDelay)
_ = json.NewEncoder(w).Encode(map[string]any{"allowed": false, "reason": "malware detected"})
}))
t.Cleanup(blockingSrv.Close)
proxy.Scanners = newTestScanGroup(t, blockingSrv.URL, false)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("evil content")),
ContentType: "application/gzip",
}
// The client disconnects long before the (genuinely malicious) verdict
// comes back; cleanup of the blocked bytes must not be skipped just
// because the client is gone.
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(20*time.Millisecond, cancel)
_, err := proxy.GetOrFetchArtifact(ctx, "npm", "evilpkg", "1.0.0", "evilpkg-1.0.0.tgz")
if err == nil {
t.Fatal("expected error for blocked artifact")
}
if !errors.Is(err, ErrArtifactBlocked) {
t.Errorf("error = %v, want wrapped ErrArtifactBlocked", err)
}
cached, _ := db.GetCachedArtifact(
purl.MakePURLString("npm", "evilpkg", ""), purl.MakePURLString("npm", "evilpkg", "1.0.0"), "evilpkg-1.0.0.tgz")
if cached != nil {
t.Error("blocked artifact must never be committed to the cache database")
}
if len(store.files) != 0 {
t.Errorf("blocked artifact bytes must still be deleted even though the client disconnected mid-scan, got %d orphaned files", len(store.files))
}
}
func TestGetOrFetchArtifact_ScanErrorFailClosed(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
brokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(brokenSrv.Close)
proxy.Scanners = newTestScanGroup(t, brokenSrv.URL, false)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("content")),
ContentType: "application/gzip",
}
_, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "flaky", "1.0.0", "flaky-1.0.0.tgz")
if err == nil {
t.Fatal("expected error when scanner infrastructure fails")
}
if !errors.Is(err, ErrArtifactBlocked) {
t.Errorf("error = %v, want wrapped ErrArtifactBlocked (fail-closed default)", err)
}
if strings.Contains(err.Error(), brokenSrv.URL) {
t.Errorf("error %q leaks the internal scanner URL to the client-facing message", err.Error())
}
if !strings.Contains(err.Error(), "scan could not be completed") {
t.Errorf("error %q does not use the generic infra-failure message", err.Error())
}
cached, _ := db.GetCachedArtifact(
purl.MakePURLString("npm", "flaky", ""), purl.MakePURLString("npm", "flaky", "1.0.0"), "flaky-1.0.0.tgz")
if cached != nil {
t.Error("artifact must not be committed when scanning fails fail-closed")
}
if len(store.files) != 0 {
t.Errorf("artifact bytes must be deleted on scan infra failure, got %d files", len(store.files))
}
}
func TestGetOrFetchArtifact_ScanErrorFailOpen(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
brokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(brokenSrv.Close)
proxy.Scanners = newTestScanGroup(t, brokenSrv.URL, true)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("content")),
ContentType: "application/gzip",
}
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "flaky", "1.0.0", "flaky-1.0.0.tgz")
if err != nil {
t.Fatalf("unexpected error: %v (FailOpen must treat scanner infra failure as allowed)", err)
}
defer func() { _ = result.Reader.Close() }()
cached, err := db.GetCachedArtifact(
purl.MakePURLString("npm", "flaky", ""), purl.MakePURLString("npm", "flaky", "1.0.0"), "flaky-1.0.0.tgz")
if err != nil {
t.Fatalf("GetCachedArtifact() error: %v", err)
}
if cached == nil {
t.Error("expected artifact to be committed to the cache when scanning fails fail-open")
}
if len(store.files) == 0 {
t.Error("expected artifact bytes to remain in storage when scanning fails fail-open")
}
}
func TestGetOrFetchArtifact_ScanSurvivesClientDisconnect(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
const scanDelay = 150 * time.Millisecond
slowSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(scanDelay)
_ = json.NewEncoder(w).Encode(map[string]any{"allowed": true})
}))
t.Cleanup(slowSrv.Close)
proxy.Scanners = newTestScanGroup(t, slowSrv.URL, false)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("clean content")),
ContentType: "application/gzip",
}
// Simulate a client that disconnects shortly after issuing the request:
// its context is cancelled well before the scanner replies, but the
// scan itself must run to completion rather than being torn down with
// it.
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(20*time.Millisecond, cancel)
start := time.Now()
result, err := proxy.GetOrFetchArtifact(ctx, "npm", "leftpad", "1.0.0", "leftpad-1.0.0.tgz")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v (a cancelled client context must not be mistaken for a scanner failure)", err)
}
defer func() { _ = result.Reader.Close() }()
if elapsed < scanDelay {
t.Errorf("GetOrFetchArtifact returned after %v, want it to wait out the full scan (%v) despite client cancellation", elapsed, scanDelay)
}
cached, err := db.GetCachedArtifact(
purl.MakePURLString("npm", "leftpad", ""), purl.MakePURLString("npm", "leftpad", "1.0.0"), "leftpad-1.0.0.tgz")
if err != nil {
t.Fatalf("GetCachedArtifact() error: %v", err)
}
if cached == nil {
t.Error("expected artifact to be committed to the cache; a client disconnect must not cause a false block")
}
if len(store.files) == 0 {
t.Error("expected artifact bytes to remain in storage; a client disconnect must not delete a legitimately allowed artifact")
}
}
func TestGetOrFetchArtifact_ScanDisabledIsNoOp(t *testing.T) {
proxy, db, _, fetcher := setupTestProxy(t)
// proxy.Scanners left nil: scanning disabled.
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("content")),
ContentType: "application/gzip",
}
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "plainpkg", "1.0.0", "plainpkg-1.0.0.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
cached, err := db.GetCachedArtifact(
purl.MakePURLString("npm", "plainpkg", ""), purl.MakePURLString("npm", "plainpkg", "1.0.0"), "plainpkg-1.0.0.tgz")
if err != nil {
t.Fatalf("GetCachedArtifact() error: %v", err)
}
if cached == nil {
t.Error("expected artifact to be cached when scanning is disabled")
}
}