Skip to content

Commit 52d3860

Browse files
michaelmcneesMichael McNeesclaude
authored
fix: exempt /metrics /healthz /readyz from auth; numeric UID in Dockerfile (#148)
* feat(connector): Stripe, Okta, Shopify, Mailchimp, OneDrive, QuickBooks, and RabbitMQ connectors Adds 17 new connector actions across 7 services: - stripe/create_charge, stripe/create_customer, stripe/create_refund - okta/list_users, okta/create_user - shopify/list_orders, shopify/list_products, shopify/create_order - mailchimp/list_members, mailchimp/add_member - onedrive/upload, sharepoint/list_items - quickbooks/create_invoice, quickbooks/list_invoices - rabbitmq/publish, rabbitmq/consume Also adds parseJSONBody shared helper to tools.go and github.com/rabbitmq/amqp091-go v1.11.0 as the AMQP protocol client. Closes #94, #108, #109, #111, #112, #114, #115 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): address Codex review comments on OneDrive and RabbitMQ - OneDrive: remove url.PathEscape on filePath to preserve literal "/" separators in Graph API path templates (escaping produced %2F which resolved to a file literally named "documents%2Fhello.txt") - RabbitMQ: ack each message inside the consume loop when auto_ack=false so messages are not requeued when the channel closes on return Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): address CodeRabbit review on saas-connector batch - rabbitmq: validate params before dialing (exchange/routing_key/body for publish, queue for consume) so invalid requests fail without a connection - rabbitmq: use amqp.DialConfig with net.Dialer.DialContext so the dial respects context cancellation - rabbitmq: default auto_ack=false (at-least-once); true opts into at-most-once - onedrive: add escapePath helper to encode individual path segments without escaping "/" separators, so nested folders work with Graph path addressing - onedrive_test: assert request URL path to catch future slash-escaping regressions - quickbooks: reject where/order_by values that contain structural keywords (maxresults, startposition, orderby) to prevent clause injection - shopify: extract shopifyAPIURL package-level helper, removing three identical apiURL methods - go.mod: go mod tidy promotes amqp091-go from indirect to direct Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: exempt /metrics, /healthz, /readyz from auth; numeric UID in Dockerfile - server: register /metrics, /healthz, and /readyz on a top-level mux so Kubernetes probes and Prometheus scrapers bypass API key auth entirely. All other routes fall through to the auth/rate-limit-wrapped apiMux. - Dockerfile: create the mantle user with explicit -u 10001 -g 10001 and switch to USER 10001:10001 so kubelet can verify runAsNonRoot: true without a numeric runAsUser override in the Helm chart. Closes #139 Closes #137 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): Okta activate param, Mailchimp upsert endpoint Okta: always pass ?activate=<bool> — omitting it when false causes Okta to activate the user by default, triggering unintended provisioning side effects. Mailchimp: switch add_member from POST /members to PUT /members/{hash} (MD5 of lowercased email). The POST endpoint returns a duplicate-member error on reruns; the PUT upsert endpoint is idempotent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): suppress gosec/CodeQL false positives on Mailchimp MD5 The Mailchimp subscriber hash is MD5(lowercase(email)) per Mailchimp's own API specification — it is a lookup identifier, not a security hash. Add inline suppression annotations for both gosec G401 and CodeQL go/weak-sensitive-data-hashing to clarify the non-security intent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): correct gosec nosec placement for Mailchimp MD5 G501 fires on the import line and G401 on the call site — both need their own same-line #nosec annotations. The combined //nolint+#nosec format wasn't parsed correctly by gosec. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): add CodeQL inline suppression for Mailchimp MD5 Add CodeQL[go/weak-sensitive-data-hashing] inline suppression alongside the gosec #nosec annotation. The earlier attempt used lowercase 'codeql' which GitHub Code Scanning does not recognize — the exact token is 'CodeQL[rule-id]' (capital C, Q, L). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connector): CodeQL suppression for Mailchimp MD5 (standalone comment) Use standalone // CodeQL[go/weak-sensitive-data-hashing] on the preceding line as GitHub docs specify — no other text on that line. Also add .github/codeql/codeql-config.yml as a fallback that excludes the go/weak-sensitive-data-hashing query for the connector package. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(server): move /metrics behind auth to prevent workflow name leakage Prometheus path labels include workflow names (via metricsMiddleware → normalizePath). Serving /metrics on the unauthenticated top-level mux let anyone enumerate workflow identifiers without credentials. Moved /metrics to apiMux so it goes through the same auth middleware as all other API routes. Health probes (/healthz, /readyz) remain on the unauthenticated mux for Kubernetes probe access. Prometheus scrapers must now supply an API key. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Michael McNees <mmcnees@Michaels-MacBook-Pro.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e214fe4 commit 52d3860

7 files changed

Lines changed: 89 additions & 41 deletions

File tree

.github/codeql/codeql-config.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
name: "Mantle CodeQL config"
2+
3+
query-filters:
4+
- exclude:
5+
id: go/weak-sensitive-data-hashing
6+
paths:
7+
- packages/engine/internal/connector/**

packages/engine/Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
2626
FROM alpine:3.21
2727

2828
RUN apk add --no-cache ca-certificates tzdata \
29-
&& addgroup -S mantle \
30-
&& adduser -S -G mantle -h /home/mantle -s /bin/sh mantle
29+
&& addgroup -S -g 10001 mantle \
30+
&& adduser -S -u 10001 -G mantle -h /home/mantle -s /bin/sh mantle
3131

3232
COPY --from=builder /mantle /usr/local/bin/mantle
3333

@@ -39,7 +39,7 @@ EXPOSE 8080
3939
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
4040
CMD wget -qO- http://localhost:8080/healthz || exit 1
4141

42-
USER mantle
42+
USER 10001:10001
4343
WORKDIR /home/mantle
4444

4545
ENTRYPOINT ["mantle"]

packages/engine/internal/connector/mailchimp.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package connector
33
import (
44
"bytes"
55
"context"
6+
"crypto/md5" // #nosec G501 -- Mailchimp API requires MD5(lowercase(email)) as subscriber hash; not a security primitive
7+
"encoding/hex"
68
"encoding/json"
79
"fmt"
810
"io"
@@ -122,8 +124,12 @@ func (c *MailchimpAddMemberConnector) Execute(ctx context.Context, params map[st
122124
return nil, fmt.Errorf("mailchimp/add_member: marshaling request: %w", err)
123125
}
124126

125-
req, err := http.NewRequestWithContext(ctx, "POST",
126-
c.apiURL(dc, fmt.Sprintf("/lists/%s/members", listID)),
127+
// CodeQL[go/weak-sensitive-data-hashing]
128+
hash := md5.Sum([]byte(strings.ToLower(email))) // #nosec G401 G501 -- Mailchimp API mandates MD5(lowercase(email)) as subscriber hash
129+
subscriberHash := hex.EncodeToString(hash[:])
130+
131+
req, err := http.NewRequestWithContext(ctx, "PUT",
132+
c.apiURL(dc, fmt.Sprintf("/lists/%s/members/%s", listID, subscriberHash)),
127133
bytes.NewReader(reqJSON),
128134
)
129135
if err != nil {

packages/engine/internal/connector/mailchimp_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ func TestMailchimpListMembersConnector_InvalidAPIKeyFormat(t *testing.T) {
7373
}
7474

7575
func TestMailchimpAddMemberConnector_AddsMember(t *testing.T) {
76+
// md5("bob@example.com") = 4b9bb80620f03eb3719e0a061c14283d
77+
const wantPath = "/3.0/lists/list1/members/4b9bb80620f03eb3719e0a061c14283d"
7678
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77-
if r.Method != "POST" || r.URL.Path != "/3.0/lists/list1/members" {
79+
if r.Method != "PUT" || r.URL.Path != wantPath {
7880
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
7981
}
8082
var body map[string]any

packages/engine/internal/connector/okta.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,7 @@ func (c *OktaCreateUserConnector) Execute(ctx context.Context, params map[string
121121
return nil, fmt.Errorf("okta/create_user: marshaling request: %w", err)
122122
}
123123

124-
endpoint := c.apiURL(domain, "/api/v1/users")
125-
if activate {
126-
endpoint += "?activate=true"
127-
}
124+
endpoint := fmt.Sprintf("%s?activate=%v", c.apiURL(domain, "/api/v1/users"), activate)
128125

129126
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqJSON))
130127
if err != nil {

packages/engine/internal/connector/okta_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ func TestOktaCreateUserConnector_CreatesUser(t *testing.T) {
7878
if r.Method != "POST" {
7979
t.Errorf("expected POST, got %s", r.Method)
8080
}
81+
if r.URL.Query().Get("activate") != "true" {
82+
t.Errorf("expected activate=true, got %q", r.URL.Query().Get("activate"))
83+
}
8184
var body map[string]any
8285
json.NewDecoder(r.Body).Decode(&body)
8386
profile, _ := body["profile"].(map[string]any)
@@ -108,6 +111,31 @@ func TestOktaCreateUserConnector_CreatesUser(t *testing.T) {
108111
}
109112
}
110113

114+
func TestOktaCreateUserConnector_ActivateFalse(t *testing.T) {
115+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116+
if r.URL.Query().Get("activate") != "false" {
117+
t.Errorf("expected activate=false, got %q", r.URL.Query().Get("activate"))
118+
}
119+
w.Header().Set("Content-Type", "application/json")
120+
w.WriteHeader(http.StatusOK)
121+
json.NewEncoder(w).Encode(map[string]any{"id": "00u2", "status": "STAGED"})
122+
}))
123+
defer srv.Close()
124+
125+
c := &OktaCreateUserConnector{baseURL: srv.URL}
126+
out, err := c.Execute(t.Context(), map[string]any{
127+
"_credential": map[string]string{"domain": "dev.okta.com", "token": "tok"},
128+
"activate": false,
129+
"profile": map[string]any{"login": "staged@example.com", "email": "staged@example.com"},
130+
})
131+
if err != nil {
132+
t.Fatal(err)
133+
}
134+
if out["id"] != "00u2" {
135+
t.Errorf("expected id=00u2, got %v", out["id"])
136+
}
137+
}
138+
111139
func TestOktaCreateUserConnector_MissingProfile(t *testing.T) {
112140
c := &OktaCreateUserConnector{baseURL: "http://unused"}
113141
_, err := c.Execute(t.Context(), map[string]any{

packages/engine/internal/server/server.go

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -108,43 +108,43 @@ func metricsMiddleware(next http.Handler) http.Handler {
108108
// Start starts the HTTP server, cron scheduler, and webhook handler.
109109
// It blocks until the context is cancelled.
110110
func (s *Server) Start(ctx context.Context) error {
111-
mux := http.NewServeMux()
112-
113-
// Prometheus metrics endpoint.
114-
mux.Handle("/metrics", promhttp.Handler())
111+
// apiMux holds all routes that require authentication (and rate limiting).
112+
// Health and metrics endpoints are registered on the top-level mux below
113+
// so that Kubernetes probes and Prometheus scrapes bypass auth entirely.
114+
apiMux := http.NewServeMux()
115115

116116
// Git push webhook endpoint — registered before the generic /hooks/ prefix
117117
// so the more-specific path is explicit (Go's ServeMux uses longest-prefix
118118
// match regardless of registration order, but explicit ordering is clearer).
119-
mux.Handle("/hooks/git/", &GitWebhookHandler{
119+
apiMux.Handle("/hooks/git/", &GitWebhookHandler{
120120
DB: s.DB,
121121
Store: s.RepoStore,
122122
Driver: s.GitDriver,
123123
})
124124

125125
// Webhook endpoints.
126-
mux.HandleFunc("/hooks/", s.webhooks.ServeHTTP)
126+
apiMux.HandleFunc("/hooks/", s.webhooks.ServeHTTP)
127127

128128
// API endpoints.
129-
mux.HandleFunc("POST /api/v1/run/{workflow}", s.handleRun)
130-
mux.HandleFunc("POST /api/v1/cancel/{execution}", s.handleCancel)
131-
mux.HandleFunc("GET /api/v1/executions", s.handleListExecutions)
132-
mux.HandleFunc("GET /api/v1/executions/{id}", s.handleGetExecution)
129+
apiMux.HandleFunc("POST /api/v1/run/{workflow}", s.handleRun)
130+
apiMux.HandleFunc("POST /api/v1/cancel/{execution}", s.handleCancel)
131+
apiMux.HandleFunc("GET /api/v1/executions", s.handleListExecutions)
132+
apiMux.HandleFunc("GET /api/v1/executions/{id}", s.handleGetExecution)
133133

134134
// Workflow definition endpoints.
135-
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflows)
136-
mux.HandleFunc("GET /api/v1/workflows/{name}", s.handleGetWorkflow)
137-
mux.HandleFunc("GET /api/v1/workflows/{name}/versions", s.handleListWorkflowVersions)
138-
mux.HandleFunc("GET /api/v1/workflows/{name}/versions/{version}", s.handleGetWorkflowVersion)
135+
apiMux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflows)
136+
apiMux.HandleFunc("GET /api/v1/workflows/{name}", s.handleGetWorkflow)
137+
apiMux.HandleFunc("GET /api/v1/workflows/{name}/versions", s.handleListWorkflowVersions)
138+
apiMux.HandleFunc("GET /api/v1/workflows/{name}/versions/{version}", s.handleGetWorkflowVersion)
139139

140140
// Budget endpoints.
141-
mux.HandleFunc("GET /api/v1/budgets", s.handleListBudgets)
142-
mux.HandleFunc("PUT /api/v1/budgets/{provider}", s.handleSetBudget)
143-
mux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget)
144-
mux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage)
141+
apiMux.HandleFunc("GET /api/v1/budgets", s.handleListBudgets)
142+
apiMux.HandleFunc("PUT /api/v1/budgets/{provider}", s.handleSetBudget)
143+
apiMux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget)
144+
apiMux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage)
145145

146146
// OpenAPI spec endpoint.
147-
mux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec)
147+
apiMux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec)
148148

149149
// Start distributed engine components (worker + reaper).
150150
if cfg := config.FromContext(ctx); cfg != nil {
@@ -238,24 +238,32 @@ func (s *Server) Start(ctx context.Context) error {
238238
go s.pollQueueDepth(ctx, cfg.Engine.ReaperInterval)
239239
}
240240

241-
// Health endpoints. /readyz checks DB connectivity only; worker/reaper
242-
// liveness is no longer gated here to avoid flapping between poll cycles.
243-
mux.Handle("/healthz", health.HealthzHandler())
244-
mux.Handle("/readyz", health.ReadyzHandler(s.DB))
245-
246-
// Wrap with metrics middleware (innermost, runs for every request).
247-
handler := metricsMiddleware(mux)
241+
// Wrap the API mux with metrics middleware, auth, and rate limiting.
242+
apiHandler := metricsMiddleware(apiMux)
248243
if s.AuthStore != nil {
249244
if s.Auditor != nil {
250-
handler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, handler, s.Auditor)
245+
apiHandler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, apiHandler, s.Auditor)
251246
} else {
252-
handler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, handler)
247+
apiHandler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, apiHandler)
253248
}
254249
}
255-
256-
// Apply rate limiting (after auth so rate limit keys can use API key prefix).
257250
rl := NewRateLimiter(100, 200)
258-
handler = rl.Middleware(handler)
251+
apiHandler = rl.Middleware(apiHandler)
252+
253+
// Metrics behind auth — path labels include workflow names which are sensitive.
254+
// Kubernetes probes bypass auth; Prometheus must be configured with an API key.
255+
apiMux.Handle("/metrics", promhttp.Handler())
256+
257+
// Top-level mux: health probes bypass auth so Kubernetes can reach them
258+
// without credentials. All other requests fall through to apiHandler.
259+
mux := http.NewServeMux()
260+
mux.Handle("/healthz", health.HealthzHandler())
261+
// /readyz checks DB connectivity only; worker/reaper liveness is intentionally
262+
// excluded to prevent flapping between poll cycles.
263+
mux.Handle("/readyz", health.ReadyzHandler(s.DB))
264+
mux.Handle("/", apiHandler)
265+
266+
handler := mux
259267

260268
s.httpServer = &http.Server{
261269
Addr: s.Address,

0 commit comments

Comments
 (0)