Files
cairnobs/api/agents/handler_test.go
T
jcoffey-dev 4b5dae5879 Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings
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.
2026-08-18 23:53:20 -07:00

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
}