mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-09-16 15:52:05 -04:00
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>
119 lines
3.1 KiB
Go
119 lines
3.1 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHTTPScanner_Scan(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
respStatus int
|
|
respBody string
|
|
wantAllowed bool
|
|
wantReason string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "allowed",
|
|
respStatus: http.StatusOK,
|
|
respBody: `{"allowed": true}`,
|
|
wantAllowed: true,
|
|
},
|
|
{
|
|
name: "blocked with reason and findings",
|
|
respStatus: http.StatusOK,
|
|
respBody: `{"allowed": false, "reason": "malware detected", "findings": [{"severity": "high", "title": "EICAR", "description": "test signature"}]}`,
|
|
wantAllowed: false,
|
|
wantReason: "malware detected",
|
|
},
|
|
{
|
|
name: "non-200 status is an error",
|
|
respStatus: http.StatusInternalServerError,
|
|
respBody: `{}`,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "malformed JSON is an error",
|
|
respStatus: http.StatusOK,
|
|
respBody: `not json`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var got Request
|
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
|
t.Errorf("decode request body: %v", err)
|
|
}
|
|
if got.Ecosystem != "npm" || got.Name != "left-pad" || got.FetchURL == "" {
|
|
t.Errorf("unexpected request body: %+v", got)
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer secret" {
|
|
t.Errorf("missing/incorrect Authorization header: %q", r.Header.Get("Authorization"))
|
|
}
|
|
|
|
w.WriteHeader(tt.respStatus)
|
|
_, _ = w.Write([]byte(tt.respBody))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewHTTPScanner("test", srv.URL, map[string]string{"Authorization": "Bearer secret"}, nil)
|
|
|
|
result, err := s.Scan(context.Background(), Request{
|
|
Ecosystem: "npm",
|
|
Name: "left-pad",
|
|
Version: "1.0.0",
|
|
FetchURL: srv.URL + "/fetch",
|
|
})
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("Scan() error = nil, want error")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Scan() unexpected error: %v", err)
|
|
}
|
|
if result.Allowed != tt.wantAllowed {
|
|
t.Errorf("Allowed = %v, want %v", result.Allowed, tt.wantAllowed)
|
|
}
|
|
if result.Reason != tt.wantReason {
|
|
t.Errorf("Reason = %q, want %q", result.Reason, tt.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPScanner_Scan_ContextTimeout(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(50 * time.Millisecond)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"allowed": true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewHTTPScanner("slow", srv.URL, nil, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := s.Scan(ctx, Request{Ecosystem: "npm", Name: "left-pad"})
|
|
if err == nil {
|
|
t.Fatal("Scan() error = nil, want timeout error")
|
|
}
|
|
}
|
|
|
|
func TestHTTPScanner_Name(t *testing.T) {
|
|
s := NewHTTPScanner("clamav", "http://example.invalid", nil, nil)
|
|
if got := s.Name(); got != "clamav" {
|
|
t.Errorf("Name() = %q, want %q", got, "clamav")
|
|
}
|
|
}
|