diff --git a/internal/handlers/stack.go b/internal/handlers/stack.go index d2bc2c65..297cbfd0 100644 --- a/internal/handlers/stack.go +++ b/internal/handlers/stack.go @@ -1265,30 +1265,38 @@ func (h *StackHandler) UpdateEnv(c *fiber.Ctx) error { "keys_deleted": deletes, "total_after": len(merged), }) - go func(teamID uuid.UUID, stackID uuid.UUID, slug string, meta []byte) { + // safego.Go (not a bare `go func`) so a panic in the audit insert is + // recovered instead of crashing the worker goroutine / process — matches + // the runStackDeploy/runStackRedeploy launches in this file. + safego.Go("stack.env.audit", func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if aErr := models.InsertAuditEvent(ctx, h.db, models.AuditEvent{ - TeamID: teamID, + TeamID: team.ID, Actor: auditActorSystem, Kind: "stack.env.updated", ResourceType: "stack", - ResourceID: uuid.NullUUID{UUID: stackID, Valid: true}, + ResourceID: uuid.NullUUID{UUID: stack.ID, Valid: true}, Summary: "updated env vars on stack " + slug + "", - Metadata: meta, + Metadata: auditMeta, }); aErr != nil { slog.Warn("stack.env.audit_failed", - "error", aErr, "team_id", teamID, "stack_id", stackID, "slug", slug) + "error", aErr, "team_id", team.ID, "stack_id", stack.ID, "slug", slug) } - }(team.ID, stack.ID, slug, auditMeta) + }) slog.Info("stack.env.updated", "slug", slug, "team_id", team.ID, "stack_id", stack.ID, "keys_set", len(body.Env)-deletes, "keys_deleted", deletes, "total_after", len(merged)) return c.JSON(fiber.Map{ - "ok": true, - "env": merged, + "ok": true, + // Redact outbound env vars — mirrors DeployHandler.UpdateEnv and + // GET /deploy/:id. The stored value (persisted above) is the + // unredacted merged map; only the response JSON is masked so + // secrets carried over from earlier PATCHes never echo in cleartext + // into proxy logs / agent transcripts. + "env": redactEnvVars(merged), "message": "Env vars persisted. Call POST /stacks/" + slug + "/redeploy to apply.", }) } diff --git a/internal/handlers/stack_env_persist_test.go b/internal/handlers/stack_env_persist_test.go index fdb95d5d..003c25ce 100644 --- a/internal/handlers/stack_env_persist_test.go +++ b/internal/handlers/stack_env_persist_test.go @@ -119,10 +119,14 @@ func TestStack_PatchEnv_PersistsAndReturns(t *testing.T) { } require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.True(t, body.OK) + // The RESPONSE redacts secret-bearing values (bug bash #1): DATABASE_URL + // (key contains "URL") is masked to "***", while a non-secret key like + // NODE_ENV passes through. The STORED value stays unredacted — asserted + // against the DB below. This mirrors DeployHandler.UpdateEnv. assert.Equal(t, map[string]string{ - "DATABASE_URL": "postgres://example", + "DATABASE_URL": "***", "NODE_ENV": "production", - }, body.Env, "response carries the merged env") + }, body.Env, "response masks secret values but keeps non-secret keys") // DB round-trip — pre-fix this would still be `{}` because the // handler dropped the payload. With migration 062 + UpdateStackEnvVars