This is a large squashed commit covering two batches of prior uncommitted work plus a full security-audit remediation pass, kept together because go.mod/go.sum and several shared files (main.go, handler.go) were touched by both and splitting risked non-building intermediate commits. Features (built earlier, previously uncommitted): - Local username/password login for single-tenant deployments with no SSO configured (api/localauth, alerting/internal/sessioncheck, sentryctl users, web/src/routes/login, metadata migrations 0040/0041). - Remotely-editable additional log file paths for agents, on top of their existing primary source (api/agents, agent/sentry-agent extra-file-path diffing, web agent config UI). - IPv4/IPv6 addresses reported alongside other host system metrics. Security audit remediation (this pass, all live-verified in production): - Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...) in the raw-SQL query escape hatch. - High: deny sensitive paths and require Admin to add agent extra_file_paths (Editor could previously point an agent at /etc/shadow or an SSH key); alerting webhook targets now validate against internal/metadata/loopback addresses, both at creation and send time; alerting's session middleware now enforces an Editor+ floor on mutating requests instead of "any authenticated session"; bumped goxmldsig to close a SAML signature-verification bypass (GO-2026-4753). - Medium: per-IP login rate limiting; security response headers (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy) on web/nginx.conf; a DevCredentialWarnings check in every Go service's config loader, logging loudly at startup if a deployment is still on docker-compose.yml's literal dev-only credentials; dependency bumps (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected Go module and both Rust crates, including a previously-uncovered x/net vulnerability in deploy/operator; a new security-scan.yml CI workflow running cargo-deny/govulncheck/npm-audit, mirroring the existing license-compliance.yml matrix shape. - Low: removed sentryctl's plaintext --password flag (shell history/`ps` exposure) in favor of stdin and a --password-stdin flag for reset-password's optional specific-password path; a dummy bcrypt comparison closes a login response-time username-enumeration side-channel.
424 lines
14 KiB
Go
424 lines
14 KiB
Go
package agents
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/sentry/sentry/api/authz"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// fakeStore enforces tenant scoping the same way store.go's real
|
|
// pgx-backed Store does (WHERE tenant_id = ...) -- a lookup for the
|
|
// right host under the wrong tenant behaves exactly like a missing
|
|
// host, never a distinguishable "found but wrong tenant" error, so
|
|
// handler_test.go's tenant-scoping tests exercise real behavior.
|
|
type fakeStore struct {
|
|
agents map[string]*Agent // keyed by tenantID+"/"+host
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{agents: map[string]*Agent{}}
|
|
}
|
|
|
|
func (f *fakeStore) put(a Agent) {
|
|
f.agents[a.TenantID+"/"+a.Host] = &a
|
|
}
|
|
|
|
func (f *fakeStore) List(_ context.Context, tenantID string) ([]Agent, error) {
|
|
var out []Agent
|
|
for _, a := range f.agents {
|
|
if a.TenantID == tenantID {
|
|
out = append(out, *a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) Get(_ context.Context, tenantID, host string) (*Agent, error) {
|
|
a, ok := f.agents[tenantID+"/"+host]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
cp := *a
|
|
return &cp, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetOverride(_ context.Context, tenantID, host string, override ConfigOverride, updatedBy string) (*Agent, error) {
|
|
a, ok := f.agents[tenantID+"/"+host]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
a.DesiredOverride = &override
|
|
a.DesiredOverrideVersion = "v-test"
|
|
a.Pending = true
|
|
a.UpdatedBy = updatedBy
|
|
cp := *a
|
|
return &cp, nil
|
|
}
|
|
|
|
func (f *fakeStore) ClearOverride(_ context.Context, tenantID, host string) error {
|
|
a, ok := f.agents[tenantID+"/"+host]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
a.DesiredOverride = nil
|
|
a.DesiredOverrideVersion = ""
|
|
a.Pending = false
|
|
a.UpdatedBy = ""
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) IssueCommand(_ context.Context, tenantID, host, command, issuedBy string) (*Agent, error) {
|
|
a, ok := f.agents[tenantID+"/"+host]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
a.PendingCommand = command
|
|
a.CommandIssuedBy = issuedBy
|
|
cp := *a
|
|
return &cp, nil
|
|
}
|
|
|
|
// fakeCommandLogger records LogCommand calls for assertions; nil-safe
|
|
// callers should use a nil *fakeCommandLogger the same way production
|
|
// code treats a nil CommandLogger, but tests that want to assert
|
|
// logging happened construct a real one.
|
|
type fakeCommandLogger struct {
|
|
entries []CommandLogEntry
|
|
err error
|
|
}
|
|
|
|
func (f *fakeCommandLogger) LogCommand(_ context.Context, entry CommandLogEntry) error {
|
|
f.entries = append(f.entries, entry)
|
|
return f.err
|
|
}
|
|
|
|
func newTestHandler(s *fakeStore) *Handler {
|
|
return NewHandler(discardLogger(), s, nil, nil)
|
|
}
|
|
|
|
func doRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var req *http.Request
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshaling request body: %v", err)
|
|
}
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(b))
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestHandleListScopesToTenant(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
s.put(Agent{TenantID: "acme", Host: "web-02"})
|
|
h := newTestHandler(s)
|
|
|
|
rec := doRequest(t, h, "GET", "/agents", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
var got []Agent
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].Host != "web-01" {
|
|
t.Fatalf("unexpected list: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetNotFound(t *testing.T) {
|
|
h := newTestHandler(newFakeStore())
|
|
rec := doRequest(t, h, "GET", "/agents/nope", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetCrossTenantIsNotFound(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "acme", Host: "web-01"})
|
|
h := newTestHandler(s) // default tenant (no authorizer/identity)
|
|
|
|
rec := doRequest(t, h, "GET", "/agents/web-01", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 (agent belongs to a different tenant)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleSetConfigRoundTrips(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
|
|
interval := int64(30000)
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{HeartbeatIntervalMS: &interval})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var got Agent
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if got.DesiredOverride == nil || got.DesiredOverride.HeartbeatIntervalMS == nil || *got.DesiredOverride.HeartbeatIntervalMS != 30000 {
|
|
t.Fatalf("unexpected override: %+v", got.DesiredOverride)
|
|
}
|
|
if !got.Pending {
|
|
t.Fatal("expected pending=true right after setting a new override")
|
|
}
|
|
}
|
|
|
|
func TestHandleSetConfigRejectsTooSmallHeartbeatInterval(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
|
|
tooSmall := int64(100)
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{HeartbeatIntervalMS: &tooSmall})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleSetConfigExtraFilePathsRoundTrips(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
|
|
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var got Agent
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if got.DesiredOverride == nil || len(got.DesiredOverride.ExtraFilePaths) != 2 {
|
|
t.Fatalf("unexpected override: %+v", got.DesiredOverride)
|
|
}
|
|
}
|
|
|
|
func TestHandleSetConfigRejectsRelativeExtraFilePath(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
|
|
ExtraFilePaths: []string{"relative/path.log"},
|
|
})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleSetConfigRejectsTooManyExtraFilePaths(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
|
|
paths := make([]string, 21)
|
|
for i := range paths {
|
|
paths[i] = "/var/log/x.log"
|
|
}
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: paths})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleSetConfigDenylistsSensitivePaths is the regression test for
|
|
// the security-audit finding that a root, unsandboxed agent plus an
|
|
// unrestricted extra_file_paths let any Editor read arbitrary files
|
|
// (e.g. /etc/shadow, SSH keys) and have them shipped into ClickHouse.
|
|
func TestHandleSetConfigDenylistsSensitivePaths(t *testing.T) {
|
|
denied := []string{
|
|
"/etc/shadow",
|
|
"/etc/passwd",
|
|
"/root/.bash_history",
|
|
"/home/alice/.ssh/id_rsa",
|
|
"/home/alice/.ssh/authorized_keys",
|
|
"/proc/1/environ",
|
|
"/etc/sentry-agent/client-key.pem",
|
|
"/opt/app/../../etc/shadow",
|
|
"/opt/app/id_ed25519",
|
|
}
|
|
for _, p := range denied {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: []string{p}})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("path %q: status = %d, want 400 (should be denylisted), body=%s", p, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleSetConfigExtraFilePathsRequiresAdminToAdd is the regression
|
|
// test for the audit's role-floor fix: adding/changing extra_file_paths
|
|
// needs Admin, not just Editor, since it grants the agent read access to
|
|
// a new file. Purely shrinking or clearing an existing set stays at the
|
|
// Editor floor everything else in this override uses.
|
|
func TestHandleSetConfigExtraFilePathsRequiresAdminToAdd(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
editor := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleEditor}, nil)
|
|
admin := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleAdmin}, nil)
|
|
|
|
rec := doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
|
|
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
|
|
})
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("editor adding a path: status = %d, want 403", rec.Code)
|
|
}
|
|
|
|
rec = doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{
|
|
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("admin adding paths: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Shrinking back down to one path is a pure removal -- Editor should
|
|
// be allowed to do this even though they couldn't have added it.
|
|
rec = doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
|
|
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("editor removing a path: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) {
|
|
h := newTestHandler(newFakeStore())
|
|
interval := int64(30000)
|
|
rec := doRequest(t, h, "PUT", "/agents/nope/config", ConfigOverride{HeartbeatIntervalMS: &interval})
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleClearConfig(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01", Pending: true})
|
|
h := newTestHandler(s)
|
|
|
|
rec := doRequest(t, h, "DELETE", "/agents/web-01/config", nil)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204", rec.Code)
|
|
}
|
|
if s.agents["default/web-01"].Pending {
|
|
t.Fatal("expected override to be cleared")
|
|
}
|
|
}
|
|
|
|
func TestRequireEditorRoleForConfigWrites(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
authorizer := fakeAuthorizer{role: authz.RoleViewer}
|
|
h := NewHandler(discardLogger(), s, authorizer, nil)
|
|
|
|
interval := int64(30000)
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{HeartbeatIntervalMS: &interval})
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 (Viewer must not be able to edit agent config)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleIssueCommandRoundTrips(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
logger := &fakeCommandLogger{}
|
|
h := NewHandler(discardLogger(), s, nil, logger)
|
|
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/command", map[string]string{"command": "restart"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var got Agent
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if got.PendingCommand != "restart" {
|
|
t.Fatalf("PendingCommand = %q, want restart", got.PendingCommand)
|
|
}
|
|
if len(logger.entries) != 1 || logger.entries[0].Command != "restart" || logger.entries[0].Host != "web-01" {
|
|
t.Fatalf("unexpected audit log entries: %+v", logger.entries)
|
|
}
|
|
}
|
|
|
|
func TestHandleIssueCommandRejectsUnknownCommand(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
h := newTestHandler(s)
|
|
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/command", map[string]string{"command": "uninstall"})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (uninstall is not a supported command yet)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleIssueCommandUnknownHostIsNotFound(t *testing.T) {
|
|
h := newTestHandler(newFakeStore())
|
|
rec := doRequest(t, h, "PUT", "/agents/nope/command", map[string]string{"command": "restart"})
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleIssueCommandFailOpenOnLoggerError is the regression test
|
|
// for CommandLogger's documented fail-open posture: an audit-log write
|
|
// failure must not turn a legitimate command issuance into an error
|
|
// response.
|
|
func TestHandleIssueCommandFailOpenOnLoggerError(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
logger := &fakeCommandLogger{err: errors.New("audit db unreachable")}
|
|
h := NewHandler(discardLogger(), s, nil, logger)
|
|
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/command", map[string]string{"command": "restart"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 even though the audit logger failed", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAdminRoleForCommands(t *testing.T) {
|
|
s := newFakeStore()
|
|
s.put(Agent{TenantID: "default", Host: "web-01"})
|
|
authorizer := fakeAuthorizer{role: authz.RoleEditor}
|
|
h := NewHandler(discardLogger(), s, authorizer, nil)
|
|
|
|
rec := doRequest(t, h, "PUT", "/agents/web-01/command", map[string]string{"command": "restart"})
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 (Editor must not be able to issue lifecycle commands, only Admin+)", rec.Code)
|
|
}
|
|
}
|
|
|
|
type fakeAuthorizer struct {
|
|
role authz.Role
|
|
}
|
|
|
|
func (f fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
|
|
return authz.Identity{TenantID: "default", UserID: "u1", Role: f.role}, nil
|
|
}
|