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/scanfetch_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

153 lines
4.7 KiB
Go

package handler
import (
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/scanner"
)
// newEnabledScanGroup returns a scanner.Group that reports Enabled() true,
// so tests can exercise ServeScanFetch's normal signature-checking path
// rather than tripping its "scanning not configured" guard.
func newEnabledScanGroup(t testing.TB) *scanner.Group {
t.Helper()
g, err := scanner.NewGroup(config.ScanningConfig{
Enabled: true,
Timeout: "15s",
SigningKey: "test-signing-key",
Scanners: []config.ScannerConfig{
{Name: "test-scanner", URL: "http://localhost/scan", Mode: "block"},
},
}, slog.Default())
if err != nil {
t.Fatalf("scanner.NewGroup() error: %v", err)
}
return g
}
func TestServeScanFetch_ValidToken(t *testing.T) {
proxy, _, store, _ := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newEnabledScanGroup(t)
store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
req := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "artifact bytes" {
t.Errorf("body = %q, want %q", w.Body.String(), "artifact bytes")
}
}
func TestServeScanFetch_Expired(t *testing.T) {
proxy, _, store, _ := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newEnabledScanGroup(t)
store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", -time.Minute)
req := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
func TestServeScanFetch_TamperedSignature(t *testing.T) {
proxy, _, store, _ := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newEnabledScanGroup(t)
store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
tampered := strings.Replace(target, "sig=", "sig=deadbeef", 1)
req := httptest.NewRequest(http.MethodGet, tampered, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
func TestServeScanFetch_PathTraversal(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newEnabledScanGroup(t)
target := proxy.scanFetchURL("../../etc/passwd", time.Minute)
req := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
func TestServeScanFetch_ScanningDisabled(t *testing.T) {
proxy, _, store, _ := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
// proxy.Scanners left nil: scanning disabled.
store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
req := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 when scanning is disabled", w.Code)
}
}
func TestServeScanFetch_NoSigningKey(t *testing.T) {
proxy, _, store, _ := setupTestProxy(t)
// proxy.ScanSigningKey left empty.
proxy.Scanners = newEnabledScanGroup(t)
store.files["npm/lodash/4.17.21/lodash-4.17.21.tgz"] = []byte("artifact bytes")
target := proxy.scanFetchURL("npm/lodash/4.17.21/lodash-4.17.21.tgz", time.Minute)
req := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 when no signing key is configured", w.Code)
}
}
func TestServeScanFetch_MissingObject(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.ScanSigningKey = []byte("test-signing-key")
proxy.Scanners = newEnabledScanGroup(t)
target := proxy.scanFetchURL("npm/missing/1.0.0/missing-1.0.0.tgz", time.Minute)
req := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
proxy.ServeScanFetch(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}