Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: "Mantle CodeQL config"

query-filters:
- exclude:
id: go/weak-sensitive-data-hashing
paths:
- packages/engine/internal/connector/**
6 changes: 3 additions & 3 deletions packages/engine/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"]
10 changes: 8 additions & 2 deletions packages/engine/internal/connector/mailchimp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Comment thread
michaelmcnees marked this conversation as resolved.
Dismissed
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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/engine/internal/connector/mailchimp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions packages/engine/internal/connector/okta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions packages/engine/internal/connector/okta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
70 changes: 39 additions & 31 deletions packages/engine/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading