diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..8639535 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,7 @@ +name: "Mantle CodeQL config" + +query-filters: + - exclude: + id: go/weak-sensitive-data-hashing + paths: + - packages/engine/internal/connector/** diff --git a/packages/engine/Dockerfile b/packages/engine/Dockerfile index 362204d..5e0e004 100644 --- a/packages/engine/Dockerfile +++ b/packages/engine/Dockerfile @@ -26,8 +26,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ FROM alpine:3.21 RUN apk add --no-cache ca-certificates tzdata \ - && addgroup -S mantle \ - && adduser -S -G mantle -h /home/mantle -s /bin/sh mantle + && addgroup -S -g 10001 mantle \ + && adduser -S -u 10001 -G mantle -h /home/mantle -s /bin/sh mantle COPY --from=builder /mantle /usr/local/bin/mantle @@ -39,7 +39,7 @@ EXPOSE 8080 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:8080/healthz || exit 1 -USER mantle +USER 10001:10001 WORKDIR /home/mantle ENTRYPOINT ["mantle"] diff --git a/packages/engine/internal/connector/mailchimp.go b/packages/engine/internal/connector/mailchimp.go index 8a12dc5..a46f604 100644 --- a/packages/engine/internal/connector/mailchimp.go +++ b/packages/engine/internal/connector/mailchimp.go @@ -3,6 +3,8 @@ package connector import ( "bytes" "context" + "crypto/md5" // #nosec G501 -- Mailchimp API requires MD5(lowercase(email)) as subscriber hash; not a security primitive + "encoding/hex" "encoding/json" "fmt" "io" @@ -122,8 +124,12 @@ func (c *MailchimpAddMemberConnector) Execute(ctx context.Context, params map[st return nil, fmt.Errorf("mailchimp/add_member: marshaling request: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", - c.apiURL(dc, fmt.Sprintf("/lists/%s/members", listID)), + // CodeQL[go/weak-sensitive-data-hashing] + hash := md5.Sum([]byte(strings.ToLower(email))) // #nosec G401 G501 -- Mailchimp API mandates MD5(lowercase(email)) as subscriber hash + subscriberHash := hex.EncodeToString(hash[:]) + + req, err := http.NewRequestWithContext(ctx, "PUT", + c.apiURL(dc, fmt.Sprintf("/lists/%s/members/%s", listID, subscriberHash)), bytes.NewReader(reqJSON), ) if err != nil { diff --git a/packages/engine/internal/connector/mailchimp_test.go b/packages/engine/internal/connector/mailchimp_test.go index 4c04108..bdc49c6 100644 --- a/packages/engine/internal/connector/mailchimp_test.go +++ b/packages/engine/internal/connector/mailchimp_test.go @@ -73,8 +73,10 @@ func TestMailchimpListMembersConnector_InvalidAPIKeyFormat(t *testing.T) { } func TestMailchimpAddMemberConnector_AddsMember(t *testing.T) { + // md5("bob@example.com") = 4b9bb80620f03eb3719e0a061c14283d + const wantPath = "/3.0/lists/list1/members/4b9bb80620f03eb3719e0a061c14283d" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/3.0/lists/list1/members" { + if r.Method != "PUT" || r.URL.Path != wantPath { t.Errorf("unexpected %s %s", r.Method, r.URL.Path) } var body map[string]any diff --git a/packages/engine/internal/connector/okta.go b/packages/engine/internal/connector/okta.go index 0f2b6ed..bbc4ca8 100644 --- a/packages/engine/internal/connector/okta.go +++ b/packages/engine/internal/connector/okta.go @@ -121,10 +121,7 @@ func (c *OktaCreateUserConnector) Execute(ctx context.Context, params map[string return nil, fmt.Errorf("okta/create_user: marshaling request: %w", err) } - endpoint := c.apiURL(domain, "/api/v1/users") - if activate { - endpoint += "?activate=true" - } + endpoint := fmt.Sprintf("%s?activate=%v", c.apiURL(domain, "/api/v1/users"), activate) req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqJSON)) if err != nil { diff --git a/packages/engine/internal/connector/okta_test.go b/packages/engine/internal/connector/okta_test.go index 4b49d2a..f0753c3 100644 --- a/packages/engine/internal/connector/okta_test.go +++ b/packages/engine/internal/connector/okta_test.go @@ -78,6 +78,9 @@ func TestOktaCreateUserConnector_CreatesUser(t *testing.T) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } + if r.URL.Query().Get("activate") != "true" { + t.Errorf("expected activate=true, got %q", r.URL.Query().Get("activate")) + } var body map[string]any json.NewDecoder(r.Body).Decode(&body) profile, _ := body["profile"].(map[string]any) @@ -108,6 +111,31 @@ func TestOktaCreateUserConnector_CreatesUser(t *testing.T) { } } +func TestOktaCreateUserConnector_ActivateFalse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("activate") != "false" { + t.Errorf("expected activate=false, got %q", r.URL.Query().Get("activate")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"id": "00u2", "status": "STAGED"}) + })) + defer srv.Close() + + c := &OktaCreateUserConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"domain": "dev.okta.com", "token": "tok"}, + "activate": false, + "profile": map[string]any{"login": "staged@example.com", "email": "staged@example.com"}, + }) + if err != nil { + t.Fatal(err) + } + if out["id"] != "00u2" { + t.Errorf("expected id=00u2, got %v", out["id"]) + } +} + func TestOktaCreateUserConnector_MissingProfile(t *testing.T) { c := &OktaCreateUserConnector{baseURL: "http://unused"} _, err := c.Execute(t.Context(), map[string]any{ diff --git a/packages/engine/internal/server/server.go b/packages/engine/internal/server/server.go index d553559..3012edf 100644 --- a/packages/engine/internal/server/server.go +++ b/packages/engine/internal/server/server.go @@ -108,43 +108,43 @@ func metricsMiddleware(next http.Handler) http.Handler { // Start starts the HTTP server, cron scheduler, and webhook handler. // It blocks until the context is cancelled. func (s *Server) Start(ctx context.Context) error { - mux := http.NewServeMux() - - // Prometheus metrics endpoint. - mux.Handle("/metrics", promhttp.Handler()) + // apiMux holds all routes that require authentication (and rate limiting). + // Health and metrics endpoints are registered on the top-level mux below + // so that Kubernetes probes and Prometheus scrapes bypass auth entirely. + apiMux := http.NewServeMux() // Git push webhook endpoint — registered before the generic /hooks/ prefix // so the more-specific path is explicit (Go's ServeMux uses longest-prefix // match regardless of registration order, but explicit ordering is clearer). - mux.Handle("/hooks/git/", &GitWebhookHandler{ + apiMux.Handle("/hooks/git/", &GitWebhookHandler{ DB: s.DB, Store: s.RepoStore, Driver: s.GitDriver, }) // Webhook endpoints. - mux.HandleFunc("/hooks/", s.webhooks.ServeHTTP) + apiMux.HandleFunc("/hooks/", s.webhooks.ServeHTTP) // API endpoints. - mux.HandleFunc("POST /api/v1/run/{workflow}", s.handleRun) - mux.HandleFunc("POST /api/v1/cancel/{execution}", s.handleCancel) - mux.HandleFunc("GET /api/v1/executions", s.handleListExecutions) - mux.HandleFunc("GET /api/v1/executions/{id}", s.handleGetExecution) + apiMux.HandleFunc("POST /api/v1/run/{workflow}", s.handleRun) + apiMux.HandleFunc("POST /api/v1/cancel/{execution}", s.handleCancel) + apiMux.HandleFunc("GET /api/v1/executions", s.handleListExecutions) + apiMux.HandleFunc("GET /api/v1/executions/{id}", s.handleGetExecution) // Workflow definition endpoints. - mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflows) - mux.HandleFunc("GET /api/v1/workflows/{name}", s.handleGetWorkflow) - mux.HandleFunc("GET /api/v1/workflows/{name}/versions", s.handleListWorkflowVersions) - mux.HandleFunc("GET /api/v1/workflows/{name}/versions/{version}", s.handleGetWorkflowVersion) + apiMux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflows) + apiMux.HandleFunc("GET /api/v1/workflows/{name}", s.handleGetWorkflow) + apiMux.HandleFunc("GET /api/v1/workflows/{name}/versions", s.handleListWorkflowVersions) + apiMux.HandleFunc("GET /api/v1/workflows/{name}/versions/{version}", s.handleGetWorkflowVersion) // Budget endpoints. - mux.HandleFunc("GET /api/v1/budgets", s.handleListBudgets) - mux.HandleFunc("PUT /api/v1/budgets/{provider}", s.handleSetBudget) - mux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget) - mux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage) + apiMux.HandleFunc("GET /api/v1/budgets", s.handleListBudgets) + apiMux.HandleFunc("PUT /api/v1/budgets/{provider}", s.handleSetBudget) + apiMux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget) + apiMux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage) // OpenAPI spec endpoint. - mux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec) + apiMux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec) // Start distributed engine components (worker + reaper). if cfg := config.FromContext(ctx); cfg != nil { @@ -238,24 +238,32 @@ func (s *Server) Start(ctx context.Context) error { go s.pollQueueDepth(ctx, cfg.Engine.ReaperInterval) } - // Health endpoints. /readyz checks DB connectivity only; worker/reaper - // liveness is no longer gated here to avoid flapping between poll cycles. - mux.Handle("/healthz", health.HealthzHandler()) - mux.Handle("/readyz", health.ReadyzHandler(s.DB)) - - // Wrap with metrics middleware (innermost, runs for every request). - handler := metricsMiddleware(mux) + // Wrap the API mux with metrics middleware, auth, and rate limiting. + apiHandler := metricsMiddleware(apiMux) if s.AuthStore != nil { if s.Auditor != nil { - handler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, handler, s.Auditor) + apiHandler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, apiHandler, s.Auditor) } else { - handler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, handler) + apiHandler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, apiHandler) } } - - // Apply rate limiting (after auth so rate limit keys can use API key prefix). rl := NewRateLimiter(100, 200) - handler = rl.Middleware(handler) + apiHandler = rl.Middleware(apiHandler) + + // Metrics behind auth — path labels include workflow names which are sensitive. + // Kubernetes probes bypass auth; Prometheus must be configured with an API key. + apiMux.Handle("/metrics", promhttp.Handler()) + + // Top-level mux: health probes bypass auth so Kubernetes can reach them + // without credentials. All other requests fall through to apiHandler. + mux := http.NewServeMux() + mux.Handle("/healthz", health.HealthzHandler()) + // /readyz checks DB connectivity only; worker/reaper liveness is intentionally + // excluded to prevent flapping between poll cycles. + mux.Handle("/readyz", health.ReadyzHandler(s.DB)) + mux.Handle("/", apiHandler) + + handler := mux s.httpServer = &http.Server{ Addr: s.Address,