diff --git a/README.md b/README.md index a1171bc..0196c17 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ replace-with = "vouch" Vouch rewrites the sparse-index `config.json` `dl` field so cargo downloads `.crate` files through the proxy. +**Go modules**: + +```sh +GOPROXY=http://127.0.0.1:18080/go/ go mod download golang.org/x/mod@v0.35.0 +``` + +The Go adapter mirrors the [`GOPROXY` protocol](https://go.dev/ref/mod#goproxy-protocol) used by `https://proxy.golang.org`: `/@v/list`, `.info`, `.mod`, `.zip`, and `/@latest` paths are passed through using Go's case-encoded module and version path elements. Vouch uses the `.info` timestamp as the publish time for cooldown checks on `.zip` downloads. + ### Try the cooldown Pick a package version published in the last week and watch the proxy reject it: diff --git a/config.example.yaml b/config.example.yaml index 0619f15..64ffe79 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -33,6 +33,10 @@ ecosystems: enabled: false upstream: "https://index.crates.io/" path_prefix: "/crates/" + go: + enabled: false + upstream: "https://proxy.golang.org/" + path_prefix: "/go/" policy: cooldown_days: 7 diff --git a/deploy/fly/config.yaml b/deploy/fly/config.yaml index 90b3fd5..59a096d 100644 --- a/deploy/fly/config.yaml +++ b/deploy/fly/config.yaml @@ -24,6 +24,10 @@ ecosystems: enabled: true upstream: "https://index.crates.io/" path_prefix: "/crates/" + go: + enabled: true + upstream: "https://proxy.golang.org/" + path_prefix: "/go/" policy: cooldown_days: 7 diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml index e39f40b..83f47e0 100644 --- a/deploy/helm/values.yaml +++ b/deploy/helm/values.yaml @@ -166,6 +166,10 @@ ecosystems: enabled: false upstream: "https://index.crates.io/" pathPrefix: "/crates/" + go: + enabled: false + upstream: "https://proxy.golang.org/" + pathPrefix: "/go/" # -- OpenTelemetry tracing. Disabled by default. When enabled and the # OTLP/gRPC endpoint is unreachable, vouch still starts cleanly and spans diff --git a/internal/config/config.go b/internal/config/config.go index 74b9605..21af1ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -227,6 +227,11 @@ func Default() Config { Upstream: "https://pypi.org/", PathPrefix: "/pypi/", }, + "go": { + Enabled: false, + Upstream: "https://proxy.golang.org/", + PathPrefix: "/go/", + }, }, Policy: PolicyConfig{ CooldownDays: 7, diff --git a/internal/ecosystem/gomod/adapter.go b/internal/ecosystem/gomod/adapter.go new file mode 100644 index 0000000..515c024 --- /dev/null +++ b/internal/ecosystem/gomod/adapter.go @@ -0,0 +1,224 @@ +package gomod + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "golang.org/x/mod/module" + + "github.com/timreynolds/vouch/internal/core" + "github.com/timreynolds/vouch/internal/ecosystem" +) + +const DefaultPathPrefix = "/go/" + +type Adapter struct { + pathPrefix string +} + +func NewAdapter(pathPrefix string) *Adapter { + if pathPrefix == "" { + pathPrefix = DefaultPathPrefix + } + if !strings.HasSuffix(pathPrefix, "/") { + pathPrefix += "/" + } + return &Adapter{pathPrefix: pathPrefix} +} + +func (a *Adapter) Name() string { + return "go" +} + +func (a *Adapter) ParseRequest(r *http.Request) (*core.PackageRequest, error) { + requestPath := r.URL.Path + if !strings.HasPrefix(requestPath, a.pathPrefix) { + return nil, nil + } + if r.URL.RawQuery != "" { + return nil, fmt.Errorf("invalid Go module proxy request: query parameters are not supported") + } + + tail := strings.TrimLeft(strings.TrimPrefix(requestPath, a.pathPrefix), "/") + if tail == "" { + return nil, nil + } + if len(tail) > 1024 { + return nil, fmt.Errorf("invalid Go module proxy path (length %d > 1024)", len(tail)) + } + for _, r := range tail { + if r < 0x20 || r == 0x7f { + return nil, fmt.Errorf("invalid Go module proxy path %q (control character)", requestPath) + } + } + + encodedModule, endpoint, ok := splitEndpoint(tail) + if !ok { + return nil, nil + } + name, err := module.UnescapePath(encodedModule) + if err != nil { + return nil, fmt.Errorf("invalid Go module path %q: %w", encodedModule, err) + } + + kind := core.RequestKindMetadata + version := "" + if strings.HasSuffix(endpoint, ".zip") { + kind = core.RequestKindBlob + } + if endpoint != "list" && endpoint != "@latest" { + encodedVersion, ok := strings.CutSuffix(endpoint, ".info") + if !ok { + encodedVersion, ok = strings.CutSuffix(endpoint, ".mod") + } + if !ok { + encodedVersion, ok = strings.CutSuffix(endpoint, ".zip") + } + if !ok || encodedVersion == "" { + return nil, nil + } + version, err = module.UnescapeVersion(encodedVersion) + if err != nil { + return nil, fmt.Errorf("invalid Go module version %q: %w", encodedVersion, err) + } + } + + return &core.PackageRequest{ + Ecosystem: a.Name(), + Name: name, + Version: version, + Kind: kind, + Method: r.Method, + Path: requestPath, + OriginalURL: r.URL.String(), + }, nil +} + +func (a *Adapter) UpstreamPath(req core.PackageRequest) (string, error) { + return strings.TrimLeft(strings.TrimPrefix(req.Path, a.pathPrefix), "/"), nil +} + +func (a *Adapter) MetadataRequest(req core.PackageRequest) (*core.PackageRequest, error) { + if req.Kind != core.RequestKindBlob || req.Name == "" || req.Version == "" { + return nil, nil + } + encodedModule, err := module.EscapePath(req.Name) + if err != nil { + return nil, fmt.Errorf("escape Go module path: %w", err) + } + encodedVersion, err := module.EscapeVersion(req.Version) + if err != nil { + return nil, fmt.Errorf("escape Go module version: %w", err) + } + metadataPath := a.pathPrefix + encodedModule + "/@v/" + encodedVersion + ".info" + return &core.PackageRequest{ + Ecosystem: a.Name(), + Name: req.Name, + Version: req.Version, + Kind: core.RequestKindMetadata, + Method: http.MethodGet, + Path: metadataPath, + OriginalURL: metadataPath, + }, nil +} + +func (a *Adapter) DistributionMetadata(metadataBody []byte, req core.PackageRequest) (ecosystem.DistributionMetadata, error) { + if req.Kind != core.RequestKindBlob { + return ecosystem.DistributionMetadata{}, nil + } + var info struct { + Version string `json:"Version"` + Time time.Time `json:"Time"` + } + if err := json.Unmarshal(metadataBody, &info); err != nil { + return ecosystem.DistributionMetadata{}, fmt.Errorf("parse Go module info: %w", err) + } + if info.Time.IsZero() { + return ecosystem.DistributionMetadata{}, nil + } + published := info.Time.UTC() + return ecosystem.DistributionMetadata{PublishedAt: &published}, nil +} + +func (a *Adapter) RewriteMetadata(artifact *core.Artifact, _ string) error { + if artifact == nil { + return nil + } + artifact.Headers.Del("Content-Length") + artifact.Headers.Del("Etag") + artifact.Headers.Del("Content-Encoding") + artifact.ContentType = contentTypeForPath(artifact.Request.Path) + return nil +} + +func (a *Adapter) SerializeResponse(w http.ResponseWriter, r *http.Request, artifact *core.Artifact) error { + for name, values := range artifact.Headers { + if shouldForwardHeader(name) { + for _, value := range values { + w.Header().Add(name, value) + } + } + } + if artifact.ContentType != "" { + w.Header().Set("Content-Type", artifact.ContentType) + } + + status := artifact.StatusCode + if status == 0 { + status = http.StatusOK + } + if status < 200 || status >= 300 { + w.Header().Set("Content-Length", fmt.Sprintf("%d", artifact.BodySize())) + w.WriteHeader(status) + if r != nil && r.Method == http.MethodHead { + return nil + } + _, err := artifact.WriteBodyTo(w) + return err + } + return core.ServeArtifactBody(w, r, artifact) +} + +func (a *Adapter) SerializeError(w http.ResponseWriter, _ *core.PackageRequest, status int, reason string) error { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + _, err := fmt.Fprintln(w, reason) + return err +} + +func splitEndpoint(tail string) (encodedModule, endpoint string, ok bool) { + if encodedModule, _, ok = strings.Cut(tail, "/@v/"); ok { + endpoint = strings.TrimPrefix(tail[len(encodedModule)+len("/@v/"):], "/") + return encodedModule, endpoint, encodedModule != "" && endpoint != "" + } + encodedModule, ok = strings.CutSuffix(tail, "/@latest") + if !ok || encodedModule == "" { + return "", "", false + } + return encodedModule, "@latest", true +} + +func contentTypeForPath(reqPath string) string { + switch { + case strings.HasSuffix(reqPath, ".info"), strings.HasSuffix(reqPath, "/@latest"): + return "application/json" + case strings.HasSuffix(reqPath, ".zip"): + return "application/zip" + default: + return "text/plain; charset=utf-8" + } +} + +func shouldForwardHeader(name string) bool { + switch strings.ToLower(name) { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "content-length", "content-encoding", + "x-content-type-options", + "set-cookie": + return false + default: + return true + } +} diff --git a/internal/ecosystem/gomod/adapter_test.go b/internal/ecosystem/gomod/adapter_test.go new file mode 100644 index 0000000..5df0c67 --- /dev/null +++ b/internal/ecosystem/gomod/adapter_test.go @@ -0,0 +1,159 @@ +package gomod + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/timreynolds/vouch/internal/core" +) + +func TestParseRequestGOPROXYEndpoints(t *testing.T) { + adapter := NewAdapter("/go/") + tests := []struct { + path string + wantName string + wantVersion string + wantKind core.RequestKind + }{ + { + path: "/go/golang.org/x/mod/@v/list", + wantName: "golang.org/x/mod", + wantKind: core.RequestKindMetadata, + }, + { + path: "/go/golang.org/x/mod/@v/v0.35.0.info", + wantName: "golang.org/x/mod", + wantVersion: "v0.35.0", + wantKind: core.RequestKindMetadata, + }, + { + path: "/go/golang.org/x/mod/@v/v0.35.0.mod", + wantName: "golang.org/x/mod", + wantVersion: "v0.35.0", + wantKind: core.RequestKindMetadata, + }, + { + path: "/go/golang.org/x/mod/@v/v0.35.0.zip", + wantName: "golang.org/x/mod", + wantVersion: "v0.35.0", + wantKind: core.RequestKindBlob, + }, + { + path: "/go/golang.org/x/mod/@latest", + wantName: "golang.org/x/mod", + wantKind: core.RequestKindMetadata, + }, + { + path: "/go/example.com/!m/@v/v1.2.3.zip", + wantName: "example.com/M", + wantVersion: "v1.2.3", + wantKind: core.RequestKindBlob, + }, + { + path: "/go/example.com/mod/@v/v1.2.3-!r!c1.info", + wantName: "example.com/mod", + wantVersion: "v1.2.3-RC1", + wantKind: core.RequestKindMetadata, + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + got, err := adapter.ParseRequest(req) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("ParseRequest returned nil") + } + if got.Name != tt.wantName || got.Version != tt.wantVersion || got.Kind != tt.wantKind { + t.Fatalf("ParseRequest = name %q version %q kind %q, want name %q version %q kind %q", + got.Name, got.Version, got.Kind, tt.wantName, tt.wantVersion, tt.wantKind) + } + upstreamPath, err := adapter.UpstreamPath(*got) + if err != nil { + t.Fatal(err) + } + if upstreamPath != tt.path[len("/go/"):] { + t.Fatalf("UpstreamPath = %q, want %q", upstreamPath, tt.path[len("/go/"):]) + } + }) + } +} + +func TestParseRequestRejectsNonProxyShapes(t *testing.T) { + adapter := NewAdapter("/go/") + for _, path := range []string{ + "/go/", + "/go/golang.org/x/mod", + "/go/golang.org/x/mod/@v/", + "/go/golang.org/x/mod/@v/v0.35.0.txt", + "/go/golang.org/x/mod/@latest/extra", + } { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, path, nil) + got, err := adapter.ParseRequest(req) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("ParseRequest returned %#v, want nil", got) + } + }) + } +} + +func TestMetadataRequestForZipUsesInfoEndpoint(t *testing.T) { + adapter := NewAdapter("/go/") + req := core.PackageRequest{ + Ecosystem: "go", + Name: "example.com/M", + Version: "v1.2.3-RC1", + Kind: core.RequestKindBlob, + Path: "/go/example.com/!m/@v/v1.2.3-!r!c1.zip", + } + got, err := adapter.MetadataRequest(req) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("MetadataRequest returned nil") + } + want := "/go/example.com/!m/@v/v1.2.3-!r!c1.info" + if got.Path != want { + t.Fatalf("metadata path = %q, want %q", got.Path, want) + } +} + +func TestDistributionMetadataUsesInfoTime(t *testing.T) { + adapter := NewAdapter("/go/") + body := []byte(`{"Version":"v0.35.0","Time":"2026-05-01T12:34:56Z"}`) + got, err := adapter.DistributionMetadata(body, core.PackageRequest{Kind: core.RequestKindBlob}) + if err != nil { + t.Fatal(err) + } + if got.PublishedAt == nil { + t.Fatal("PublishedAt is nil") + } + want := time.Date(2026, 5, 1, 12, 34, 56, 0, time.UTC) + if !got.PublishedAt.Equal(want) { + t.Fatalf("PublishedAt = %s, want %s", got.PublishedAt, want) + } +} + +func TestSerializeErrorMatchesGOPROXYPlainText(t *testing.T) { + adapter := NewAdapter("/go/") + resp := httptest.NewRecorder() + if err := adapter.SerializeError(resp, nil, http.StatusNotFound, "upstream not found"); err != nil { + t.Fatal(err) + } + if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q, want text/plain; charset=utf-8", got) + } + if resp.Body.String() != "upstream not found\n" { + t.Fatalf("body = %q", resp.Body.String()) + } +} diff --git a/internal/policy/cel/default_bundle.yaml b/internal/policy/cel/default_bundle.yaml index 8e09a61..178e92d 100644 --- a/internal/policy/cel/default_bundle.yaml +++ b/internal/policy/cel/default_bundle.yaml @@ -27,7 +27,7 @@ pre_fetch: decision: allow reason: '"publish time unknown but allowed by policy"' required: - hash: 'true' + hash: 'request.ecosystem != "go"' provenance: 'config.require_provenance && ecosystem_supports_provenance' - id: unknown_publish_denied @@ -46,7 +46,7 @@ pre_fetch: decision: allow reason: '"package age satisfies cooldown"' required: - hash: 'true' + hash: 'request.ecosystem != "go"' provenance: 'config.require_provenance && ecosystem_supports_provenance' post_fetch: diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go index ba1bea4..4b779f4 100644 --- a/internal/policy/engine_test.go +++ b/internal/policy/engine_test.go @@ -69,6 +69,29 @@ func TestEngineAllowsMatureBlobWithHashVerification(t *testing.T) { } } +func TestEngineAllowsMatureGoBlobWithoutRegistryHash(t *testing.T) { + now := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) + publishedAt := now.Add(-8 * 24 * time.Hour) + engine := newTestEngine(t, EngineConfig{CooldownDays: 7}) + + decision := engine.Evaluate(context.Background(), EvaluationInput{ + Phase: PolicyPhasePreFetch, + Request: core.PackageRequest{Ecosystem: "go", Kind: core.RequestKindBlob}, + PublishedAt: &publishedAt, + Now: now, + }) + + if decision.Decision != core.PolicyAllow { + t.Fatalf("expected mature Go blob request to be allowed, got %s", decision.Decision) + } + if decision.Required.RequireHash { + t.Fatal("expected Go module proxy blob request not to require registry hash verification") + } + if decision.Reason != ReasonCooldownSatisfied { + t.Fatalf("expected reason %q, got %q", ReasonCooldownSatisfied, decision.Reason) + } +} + func TestEngineRequiresProvenanceForNPMBlobWhenCapabilityEnabled(t *testing.T) { now := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) publishedAt := now.Add(-8 * 24 * time.Hour) diff --git a/internal/server/proxy.go b/internal/server/proxy.go index 1e9ab7f..7538631 100644 --- a/internal/server/proxy.go +++ b/internal/server/proxy.go @@ -373,7 +373,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = p.adapter.SerializeError(w, packageRequest, http.StatusBadGateway, policyDenyReason(finalDecision)) return } - if packageRequest.Kind == core.RequestKindBlob && verificationSatisfiesRequired(verification, preFetchDecision.Required) { + if packageRequest.Kind == core.RequestKindBlob && artifact.NativeDigest != "" && verificationSatisfiesRequired(verification, preFetchDecision.Required) { p.putBlob(r.Context(), artifact) p.recordTrustState(r.Context(), *packageRequest, verification) } diff --git a/internal/server/server.go b/internal/server/server.go index 966662f..7622375 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/timreynolds/vouch/internal/config" "github.com/timreynolds/vouch/internal/ecosystem" "github.com/timreynolds/vouch/internal/ecosystem/crates" + "github.com/timreynolds/vouch/internal/ecosystem/gomod" "github.com/timreynolds/vouch/internal/ecosystem/maven" "github.com/timreynolds/vouch/internal/ecosystem/npm" "github.com/timreynolds/vouch/internal/ecosystem/pypi" @@ -207,6 +208,8 @@ func newAdapter(name, prefix string) (ecosystem.Adapter, error) { return rubygems.NewAdapter(prefix), nil case "crates": return crates.NewAdapter(prefix), nil + case "go": + return gomod.NewAdapter(prefix), nil default: return nil, fmt.Errorf("unsupported ecosystem %q", name) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1c88286..b5db8fb 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -31,6 +31,48 @@ func TestNewExposesMetricsEndpoint(t *testing.T) { } } +func TestGoModuleProxyZipUsesInfoTimestampForCooldown(t *testing.T) { + zipBody := []byte("not a real zip; proxy policy does not inspect Go zip hashes") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/golang.org/x/mod/@v/v0.35.0.info": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Version":"v0.35.0","Time":"2026-04-01T00:00:00Z"}`)) + case "/golang.org/x/mod/@v/v0.35.0.zip": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipBody) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + cfg := config.Default() + cfg.Cache.ObjectStore.Driver = "noop" + cfg.Policy.CooldownDays = 7 + cfg.Ecosystems = map[string]config.EcosystemConfig{ + "go": { + Enabled: true, + Upstream: upstream.URL, + PathPrefix: "/go/", + }, + } + handler, err := New(cfg, audit.NewNoopRecorder(), truststate.NewNoopStore(), nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/go/golang.org/x/mod/@v/v0.35.0.zip", nil) + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.Code, resp.Body.String()) + } + if got := resp.Body.Bytes(); string(got) != string(zipBody) { + t.Fatalf("zip body = %q, want %q", got, zipBody) + } +} + // TestCatchAll404 pins the unified 404 body for paths that don't // match a known status endpoint or ecosystem prefix. Before the // catch-all handler, Go's mux served its default "404 page not found"