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.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+164
View File
@@ -14,6 +14,18 @@ export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http:
// disabled, no broken links.
export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL as string | undefined;
// Local login (see api/localauth's package doc comment). Baked in at
// build time same as the base URLs above -- requestFrom/alertingRequest
// below only send `credentials: 'include'` when this is true, since
// api's/alerting's own CORS stays the permissive wildcard-friendly
// WithCORS (no Access-Control-Allow-Credentials) unless the deployment
// set LOCAL_AUTH_ENABLED server-side too -- browsers categorically
// refuse to combine a credentialed fetch with a wildcard
// Access-Control-Allow-Origin, so sending credentials unconditionally
// would break every plain `docker compose up` local-dev deployment,
// which never sets either of these.
export const localAuthEnabled = import.meta.env.VITE_LOCAL_AUTH_ENABLED === 'true';
export type Language = '' | 'sql' | 'spl';
// warnings (Phase 7) is populated by the shared costguard package's
@@ -59,6 +71,7 @@ class ApiError extends Error {}
async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { 'Content-Type': 'application/json' },
...(localAuthEnabled ? { credentials: 'include' as RequestCredentials } : {}),
...init
});
if (!res.ok) {
@@ -393,6 +406,74 @@ export function injectTimeRange(query: string, earliest: string, latest: string)
return `${clauses.join(' ')} ${query}`;
}
// --- local login (single-tenant mode, see api/localauth) --------------
export type LocalSession = { user_id: string; tenant_id: string; username: string; role: string };
export function login(username: string, password: string): Promise<LocalSession & { token: string }> {
return request('/auth/login', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password })
});
}
export function logout(): Promise<void> {
return request('/auth/logout', { method: 'POST', credentials: 'include' });
}
// getLocalSession is three-state, not a boolean -- +layout.ts's route
// guard needs to tell "not logged in" (null, redirect to /login) apart
// from "this deployment doesn't have local auth turned on at all"
// ('disabled', let the request through) -- GET /auth/session is only
// ever registered server-side when LOCAL_AUTH_ENABLED is set (see
// api/localauth.Handler.RegisterRoutes' doc comment), so a 404 here
// means the latter, same "absence is a normal deployment shape" posture
// getAuthFeatures/getCurrentSession above already use for enterprise
// auth. Always sends credentials regardless of the module-level
// localAuthEnabled flag -- this is the one call the route guard makes
// unconditionally to *discover* whether local auth is on, so it can't
// rely on that flag being true first.
export async function getLocalSession(): Promise<LocalSession | 'disabled' | null> {
try {
const res = await fetch(`${apiBase}/auth/session`, { credentials: 'include' });
if (res.status === 404) return 'disabled';
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export type LocalUser = { id: string; username: string; role: string; created_at: string };
export function listUsers(): Promise<LocalUser[]> {
return request<LocalUser[]>('/auth/users', { credentials: 'include' }).then((u) => u ?? []);
}
export function createUser(username: string, password: string, role: string): Promise<LocalUser> {
return request('/auth/users', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password, role })
});
}
export function deleteUser(id: string): Promise<void> {
return request(`/auth/users/${id}`, { method: 'DELETE', credentials: 'include' });
}
// resetPassword's response only carries `password` when the caller
// didn't supply one -- see api/localauth/handler.go's
// resetPasswordResponse doc comment.
export function resetPassword(id: string, newPassword?: string): Promise<{ password?: string }> {
return request(`/auth/users/${id}/reset-password`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newPassword ? { password: newPassword } : {})
});
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
@@ -484,6 +565,7 @@ export type ConfigOverride = {
heartbeat_enabled?: boolean;
heartbeat_interval_ms?: number;
journald_unit?: string;
extra_file_paths?: string[];
};
export type Agent = {
@@ -545,3 +627,85 @@ export function issueAgentCommand(host: string, command: 'restart'): Promise<Age
body: JSON.stringify({ command })
});
}
// ---- Host CPU/memory/disk metrics ----
// No new REST endpoints -- a metrics sample is an ordinary log record
// (see agent/README.md's "Host CPU/memory/disk metrics" section and
// agent/sentry-agent/src/main.rs's send_metrics), tagged
// `sentry.metrics=true`, fetched through the same POST /query every
// other page already uses via runQuery(). Only ever set on one agent
// process per physical host, so `stats count by host` over this tag
// naturally lists real hosts, not every fragmented per-source agent
// identity `/agents` shows (see the deployment notes on why several
// agent processes can share one physical host under different
// `[agent] host` values).
export type HostSummary = { host: string; sampleCount: number };
export async function listMetricsHosts(): Promise<HostSummary[]> {
const result = await runQuery('sentry.metrics=true | stats count by host', 'spl');
const hostIdx = result.columns.indexOf('host');
const countIdx = result.columns.indexOf('count');
return result.rows.map((r) => ({ host: String(r[hostIdx]), sampleCount: Number(r[countIdx]) }));
}
export type HostMetrics = {
host: string;
timestamp: string;
cpuPercent: number;
memUsedBytes: number;
memTotalBytes: number;
diskUsedBytes: number;
diskTotalBytes: number;
// Static-or-slow-changing context (see agent/src/metrics.rs's
// Metrics doc comment) -- sent on the same record specifically so a
// viewer never has to correlate two different samples to make sense
// of the utilization numbers above (is 21% CPU busy or idle depends
// on core count; is this usage normal depends on uptime).
cpuCores: number;
osName: string;
kernelVersion: string;
arch: string;
uptimeSeconds: number;
ipv4Addresses: string[];
ipv6Addresses: string[];
};
// Reads straight out of the record's `attributes` object (already
// returned in full on every query result row) rather than trying to
// project attribute-derived fields as top-level query-language columns
// -- simpler, and doesn't depend on `fields` supporting synthetic
// attribute columns the same way filtering does.
export async function getHostMetrics(host: string): Promise<HostMetrics | null> {
const result = await runQuery(
`host="${host}" sentry.metrics=true | sort -timestamp | head 1`,
'spl'
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
const timestampIdx = result.columns.indexOf('timestamp');
const attributesIdx = result.columns.indexOf('attributes');
const attrs = (row[attributesIdx] ?? {}) as Record<string, string>;
const num = (key: string) => Number(attrs[key] ?? 0);
// Comma-joined by the agent (see agent/src/main.rs's send_metrics) --
// split back into a list here, filtering out the empty string a
// host with no addresses of a given family produces (''.split(',')
// is [''], not [], so the filter is load-bearing, not defensive).
const addrList = (key: string) => (attrs[key] ?? '').split(',').filter((a) => a !== '');
return {
host,
timestamp: String(row[timestampIdx]),
cpuPercent: num('cpu_percent'),
memUsedBytes: num('mem_used_bytes'),
memTotalBytes: num('mem_total_bytes'),
diskUsedBytes: num('disk_used_bytes'),
diskTotalBytes: num('disk_total_bytes'),
cpuCores: num('cpu_cores'),
osName: attrs['os_name'] ?? 'unknown',
kernelVersion: attrs['kernel_version'] ?? 'unknown',
arch: attrs['arch'] ?? 'unknown',
uptimeSeconds: num('uptime_seconds'),
ipv4Addresses: addrList('ipv4_addresses'),
ipv6Addresses: addrList('ipv6_addresses')
};
}