diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..73650f1 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,81 @@ +name: Security scan + +# Closes a real gap the security audit found: license-compliance.yml +# (this repo's only other workflow) checks license text, never +# vulnerabilities -- and agent/deny.toml and search/deny.toml already +# ship an [advisories] policy that nothing in CI ever invoked. Same +# matrix-per-language shape as license-compliance.yml, extended to the +# equivalent vulnerability-scanning tool per ecosystem: cargo-deny's +# other command for Rust, govulncheck for Go, npm audit for the one +# npm package. A new dependency with a known vulnerability now fails +# the build here, not months later when someone happens to re-run this +# by hand. + +on: + push: + branches: [master, main] + pull_request: + +jobs: + rust-advisories: + name: Rust vulnerability check (cargo-deny) + runs-on: ubuntu-latest + strategy: + matrix: + crate_dir: [agent, search] + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: ${{ matrix.crate_dir }}/Cargo.toml + command: check advisories + + go-vulncheck: + name: Go vulnerability check (govulncheck) + runs-on: ubuntu-latest + strategy: + matrix: + # Same module list as license-compliance.yml's go-licenses job -- + # see that job's own comment for why cli/hack-webhook-sink/ + # hack-alert-load-test are excluded (no third-party dependencies + # at audit time). + module_dir: + - api + - ingest + - alerting + - enterprise + - deploy/operator + - terraform + - proto + - hack/benchmark-fixture + - hack/windows-fixture + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module_dir }}/go.mod + - run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Check for known vulnerabilities + working-directory: ${{ matrix.module_dir }} + run: govulncheck ./... + + npm-audit: + name: npm vulnerability check (npm audit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - working-directory: web + run: npm ci + - name: Audit production dependencies + working-directory: web + # --omit=dev, not the deprecated --production: this deliberately + # only gates the runtime bundle a real deployment actually + # ships. The one known finding in web's full dependency tree + # today (a `cookie` advisory) lives entirely in the SvelteKit + # build toolchain, not the production bundle -- fixing it needs + # a deliberate, tested major-version bump, not an automated + # `audit fix --force`, so it's out of scope for this gate. + run: npm audit --omit=dev diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 4329c5e..bd3169b 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -258,7 +258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -348,9 +348,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -477,7 +477,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -737,9 +737,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -842,7 +842,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1079,7 +1079,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/agent/Dockerfile b/agent/Dockerfile index f40a1da..f7355e6 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -8,8 +8,13 @@ WORKDIR /src COPY proto ./proto COPY agent ./agent WORKDIR /src/agent +# file-tail is additive (no extra deps -- see Cargo.toml's [features] +# doc comment) and needed on any host whose logs aren't in journald +# (e.g. nginx's plain access/error log files), so it's built in +# alongside the journald default rather than left as an opt-in most +# deployments would otherwise have to remember to ask for. RUN rustup target add x86_64-unknown-linux-musl \ - && cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent + && cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent --features journald,file-tail FROM scratch COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent diff --git a/agent/README.md b/agent/README.md index 6a7c980..59de471 100644 --- a/agent/README.md +++ b/agent/README.md @@ -153,6 +153,30 @@ alerting engine already detects natively via an `absence`-condition alert rule. See `/docs/agent-heartbeat-monitoring.md` for the exact rule to create. +## Host CPU/memory/disk metrics + +Same shape as heartbeat, same reasoning: `[metrics]` in the config +(`enabled = false` by default) sends a periodic record — CPU%, memory +used/total, disk used/total for `/` — tagged `sentry.metrics=true`, with +the individual numbers as their own attributes (`cpu_percent`, +`mem_used_bytes`, `mem_total_bytes`, `disk_used_bytes`, +`disk_total_bytes`), queryable directly (e.g. `cpu_percent > 80`) since +the query language transparently maps any non-standard field name to +`attributes['field']` with automatic numeric casting. Powers the web +UI's "Hosts" nav section. Linux-only for now (`src/metrics.rs`) — reads +`/proc/stat`/`/proc/meminfo` and shells out to `df`, no new +dependencies, same "shell out to a boring, ubiquitous tool" precedent +`journalctl` already sets. + +**Enable this on only one agent process per physical host.** It's +common for one host to run several `sentry-agent` processes (one per +log source, each needing its own `[agent] host` value to work around +the `agents` table's `UNIQUE (tenant_id, host)` constraint — see +`/docs/agent-management-design.md`) — turning `[metrics]` on for more +than one of them reports the same physical machine as multiple +different "hosts" with conflicting metric series. Pick the one process +using that host's real, unoverridden hostname. + ## Running as a Windows service "A native Windows service, not a WSL wrapper" means implementing the Win32 diff --git a/agent/sentry-agent/Cargo.toml b/agent/sentry-agent/Cargo.toml index 2076de0..b6f595b 100644 --- a/agent/sentry-agent/Cargo.toml +++ b/agent/sentry-agent/Cargo.toml @@ -47,7 +47,7 @@ windows = { version = "0.58", features = [ "Win32_Security", ] } windows-service = "0.7" -quick-xml = "0.36" +quick-xml = "0.41" [build-dependencies] tonic-build = "0.12" diff --git a/agent/sentry-agent/config/agent.example.toml b/agent/sentry-agent/config/agent.example.toml index 66632aa..fac537d 100644 --- a/agent/sentry-agent/config/agent.example.toml +++ b/agent/sentry-agent/config/agent.example.toml @@ -51,6 +51,17 @@ interval = "60s" # interval = "5m" # interval = "1h" +[metrics] +# Host CPU/memory/disk usage, sent as its own periodic record the same +# way heartbeat is (see web/'s "Hosts" nav section). Off by default -- +# unlike heartbeat, this is a deliberate per-host decision: if several +# sentry-agent processes run on the same physical host (e.g. one per log +# source), enable this on only ONE of them, or the same host will report +# multiple conflicting metric series. Linux-only for now. Root disk ("/") +# only -- not configurable in this release. +enabled = false +interval = "60s" + [ingest] endpoint = "https://ingest.internal:4317" diff --git a/agent/sentry-agent/src/config.rs b/agent/sentry-agent/src/config.rs index 3ea7d76..df7e253 100644 --- a/agent/sentry-agent/src/config.rs +++ b/agent/sentry-agent/src/config.rs @@ -15,6 +15,7 @@ pub struct Config { pub source: SourceConfig, pub batch: BatchConfig, pub heartbeat: HeartbeatConfig, + pub metrics: MetricsConfig, pub ingest: IngestConfig, pub tls: TlsConfig, } @@ -168,6 +169,31 @@ impl Default for HeartbeatConfig { } } +/// Off by default -- same "off unless configured" posture as every other +/// optional feature in this codebase -- since collecting host metrics is +/// a deliberate per-host decision (see /agent/README.md's Hosts-feature +/// notes: only one agent process per physical host should have this on, +/// to avoid duplicate/fragmented metric series when several agent +/// processes share a host under different `[agent] host` overrides). +/// Sent independently of `batch`, same reasoning and mechanism as +/// `HeartbeatConfig` above (see main.rs's `send_metrics`). +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct MetricsConfig { + pub enabled: bool, + #[serde(deserialize_with = "deserialize_duration")] + pub interval: Duration, +} + +impl Default for MetricsConfig { + fn default() -> Self { + Self { + enabled: false, + interval: Duration::from_secs(60), + } + } +} + /// Parses a human-friendly duration string with an explicit unit suffix /// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary /// `earliest=`/`latest=` use in the query language @@ -237,6 +263,30 @@ mod heartbeat_config_tests { } } +#[cfg(test)] +mod metrics_config_tests { + use super::*; + + #[test] + fn default_is_60_seconds_and_disabled() { + let cfg = MetricsConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.interval, Duration::from_secs(60)); + } + + #[test] + fn toml_field_parses_via_deserialize() { + #[derive(Deserialize)] + struct Wrapper { + #[serde(default)] + metrics: MetricsConfig, + } + let w: Wrapper = toml::from_str("[metrics]\nenabled = true\ninterval = \"30s\"\n").unwrap(); + assert!(w.metrics.enabled); + assert_eq!(w.metrics.interval, Duration::from_secs(30)); + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct IngestConfig { diff --git a/agent/sentry-agent/src/main.rs b/agent/sentry-agent/src/main.rs index 4e10c34..9a30ec0 100644 --- a/agent/sentry-agent/src/main.rs +++ b/agent/sentry-agent/src/main.rs @@ -1,6 +1,7 @@ mod batch; mod config; mod grpc; +mod metrics; mod source; #[cfg(windows)] @@ -22,6 +23,8 @@ use clap::Parser; use config::Config; use pb::agent::v1::{agent_control_client::AgentControlClient, AgentCommand, CheckInRequest, DesiredOverride, ReportedConfig}; use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity}; +use std::collections::HashMap; +use std::collections::HashSet; use std::path::PathBuf; use std::time::Duration; use tokio::sync::mpsc; @@ -92,12 +95,33 @@ pub async fn run_agent(config_path: Option) -> Result<()> { let host = cfg.agent.host.clone().unwrap_or_else(default_hostname); let service = cfg.agent.service.clone(); + // One long-lived channel for the whole process, not one per source + // task -- both the primary source and every extra-file-path task + // (see apply_override's extra_file_paths handling) send into clones + // of the same `tx`. This matters specifically because the primary + // source can be aborted and respawned at runtime (a journald_unit + // override): if each respawn created a *new* channel the way this + // used to work, any extra-file-path task still holding a clone of + // the old `tx` would be silently orphaned (sending into a channel + // whose `rx` had just been replaced/dropped). Creating the channel + // once and only ever swapping the *task* (never the channel) avoids + // that entirely. + let (tx, mut rx) = mpsc::channel(1024); + // source_cfg is mutable: a remote DesiredOverride's journald_unit // field (see apply_override below) can change it at runtime, which // means aborting and respawning the source task with the new - // filter -- source_handle/rx are mutable for the same reason. + // filter -- source_handle is mutable for the same reason. let mut source_cfg = cfg.source.clone(); - let (mut source_handle, mut rx) = spawn_source_task(source_cfg.clone()); + let mut source_handle = spawn_source_task(source_cfg.clone(), tx.clone()); + + // Extra file paths an operator has remotely added via the web UI + // (see apply_override's extra_file_paths handling) -- empty until + // the first such override arrives. Keyed by path so a later + // override with a different path list can be diffed against what's + // already running: abort tasks for paths that were removed, spawn + // tasks for paths that are new, leave everything else untouched. + let mut extra_file_tasks: HashMap> = HashMap::new(); let channel = grpc::connect(&cfg.ingest, &cfg.tls) .await @@ -116,6 +140,12 @@ pub async fn run_agent(config_path: Option) -> Result<()> { let mut flush_interval = Duration::from_millis(cfg.batch.flush_interval_ms); let mut heartbeat_enabled = cfg.heartbeat.enabled; let mut heartbeat_interval = cfg.heartbeat.interval; + // Not remotely overridable (unlike batch/heartbeat above) -- see + // /agent/README.md's Hosts-feature notes on why this is a + // deliberate, per-host, config-file-only decision, not something + // the web UI can flip on for an arbitrary agent. + let metrics_enabled = cfg.metrics.enabled; + let metrics_interval = cfg.metrics.interval; // Empty until the first override is ever applied -- echoed back on // every CheckIn as-is so the server can tell "pending" (an edit // exists this agent hasn't picked up) from "applied." @@ -134,8 +164,17 @@ pub async fn run_agent(config_path: Option) -> Result<()> { // heartbeat_enabled. let mut heartbeat_ticker = tokio::time::interval(heartbeat_interval.max(Duration::from_millis(50))); + // Unlike heartbeat_ticker, metrics has no secondary purpose (no + // CheckIn-equivalent side effect) to keep it running when disabled, + // so the select! arm below skips it entirely via `if metrics_enabled` + // rather than always firing and conditionally acting. + let mut metrics_ticker = tokio::time::interval(metrics_interval.max(Duration::from_millis(50))); + loop { tokio::select! { + _ = metrics_ticker.tick(), if metrics_enabled => { + send_metrics(&mut client, &host, &service).await; + } _ = heartbeat_ticker.tick() => { if heartbeat_enabled { send_heartbeat(&mut client, &host, &service).await; @@ -164,7 +203,8 @@ pub async fn run_agent(config_path: Option) -> Result<()> { &mut batch_max_size, &mut flush_interval, &mut heartbeat_enabled, &mut heartbeat_interval, &mut batcher, &mut ticker, &mut heartbeat_ticker, - &mut source_cfg, &mut source_handle, &mut rx, + &mut source_cfg, &mut source_handle, + &mut extra_file_tasks, &tx, &mut client, ).await; applied_override_version = ov.version.clone(); @@ -272,7 +312,8 @@ async fn apply_override( heartbeat_ticker: &mut tokio::time::Interval, source_cfg: &mut config::SourceConfig, source_handle: &mut tokio::task::JoinHandle<()>, - rx: &mut mpsc::Receiver, + extra_file_tasks: &mut HashMap>, + tx: &source::LineSender, client: &mut LogIngestClient, ) { if let Some(v) = ov.batch_max_size { @@ -310,19 +351,57 @@ async fn apply_override( if *current_unit != new_unit { *source_cfg = config::SourceConfig::Journald { unit: new_unit }; source_handle.abort(); - let (new_handle, new_rx) = spawn_source_task(source_cfg.clone()); - *source_handle = new_handle; - *rx = new_rx; + *source_handle = spawn_source_task(source_cfg.clone(), tx.clone()); tracing::info!(unit = ?unit, "applied remote journald unit override, restarted source"); } } } + + // Extra file paths to tail alongside whatever the primary source + // above is -- see agent_control.proto's DesiredOverride. + // extra_file_paths comment. Diffed against what's already running + // (extra_file_tasks' keys) rather than blindly tearing everything + // down and respawning: an edit to, say, heartbeat_interval_ms must + // never interrupt a file that's already being tailed and hasn't + // changed. `ov.extra_file_paths` is the complete desired list every + // time (never a partial patch -- see the proto field's own doc + // comment), so anything not in it gets removed. + let desired: HashSet = ov.extra_file_paths.iter().map(PathBuf::from).collect(); + let current: HashSet = extra_file_tasks.keys().cloned().collect(); + + for removed in current.difference(&desired) { + if let Some(handle) = extra_file_tasks.remove(removed) { + handle.abort(); + tracing::info!(path = %removed.display(), "stopped tailing removed extra file path"); + } + } + for added in desired.difference(¤t) { + extra_file_tasks.insert(added.clone(), spawn_extra_file_task(added.clone(), tx.clone())); + tracing::info!(path = %added.display(), "started tailing new extra file path"); + } } -fn spawn_source_task(source_cfg: config::SourceConfig) -> (tokio::task::JoinHandle<()>, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(1024); - let handle = tokio::spawn(spawn_source(source_cfg, tx)); - (handle, rx) +fn spawn_source_task(source_cfg: config::SourceConfig, tx: source::LineSender) -> tokio::task::JoinHandle<()> { + tokio::spawn(spawn_source(source_cfg, tx)) +} + +/// Same shape as `spawn_source`'s own per-source-kind feature gating -- +/// an extra file path is really just another `File` source, tailed +/// from the end (no `from_beginning` knob for these; matches the +/// primary `file` source's own sensible default), feeding into the +/// same shared channel every other source in this process uses. +#[cfg_attr(not(feature = "file-tail"), allow(unused_variables))] +fn spawn_extra_file_task(path: PathBuf, tx: source::LineSender) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + #[cfg(feature = "file-tail")] + let result = source::file_tail::run(&path, false, tx).await; + #[cfg(not(feature = "file-tail"))] + let result: Result<(), anyhow::Error> = + Err(anyhow::anyhow!("this build was compiled without the `file-tail` feature")); + if let Err(e) = result { + tracing::error!(error = %e, path = %path.display(), "extra file path exited with error"); + } + }) } fn source_kind_name(cfg: &config::SourceConfig) -> String { @@ -418,6 +497,65 @@ async fn send_heartbeat(client: &mut LogIngestClient, host: &str, servi } } +/// Same "no new proto, no new ingest code, no new ClickHouse schema" +/// shape as `send_heartbeat` above -- a metrics sample is just another +/// tagged `LogRecord`, distinguished by the `sentry.metrics` attribute. +/// Unlike heartbeat, the numeric fields themselves are real query-language +/// attributes too (`cpu_percent`, `mem_used_bytes`, etc.) rather than +/// being folded into `message` -- confirmed before building this that +/// arbitrary attribute names are transparently queryable/comparable as +/// numbers (`api/querylang/executor/sql.go`'s `topLevelFields` fallback +/// to `attributes['field']` with automatic numeric casting), so there's +/// no need to encode them as a JSON blob in `message` and parse it +/// client-side instead. +async fn send_metrics(client: &mut LogIngestClient, host: &str, service: &str) { + let m = match metrics::collect("/").await { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = %e, host, "collecting host metrics failed"); + return; + } + }; + let record = LogRecord { + timestamp_unix_nano: now_unix_nanos(), + host: host.to_string(), + service: service.to_string(), + severity: Severity::Info as i32, + message: "host metrics".to_string(), + attributes: std::collections::HashMap::from([ + ("sentry.metrics".to_string(), "true".to_string()), + ("cpu_percent".to_string(), format!("{:.2}", m.cpu_percent)), + ("mem_used_bytes".to_string(), m.mem_used_bytes.to_string()), + ("mem_total_bytes".to_string(), m.mem_total_bytes.to_string()), + ("disk_used_bytes".to_string(), m.disk_used_bytes.to_string()), + ("disk_total_bytes".to_string(), m.disk_total_bytes.to_string()), + // Static-or-slow-changing context, not utilization numbers -- + // sent on the same record so a viewer never has to correlate + // two different samples to make sense of the numbers above + // (see metrics::Metrics's doc comment). + ("cpu_cores".to_string(), m.cpu_cores.to_string()), + ("os_name".to_string(), m.os_name.clone()), + ("kernel_version".to_string(), m.kernel_version.clone()), + ("arch".to_string(), m.arch.to_string()), + ("uptime_seconds".to_string(), m.uptime_seconds.to_string()), + // Comma-joined -- LogRecord attributes are string-valued, + // and a host can have more than one address per family + // (multi-NIC, or a v6 privacy/temporary address alongside + // the stable one). Empty string, not an omitted key, when + // a host genuinely has none of a given family -- matches + // every other soft-failed context field here (see + // metrics::collect's unwrap_or_default for this one). + ("ipv4_addresses".to_string(), m.ipv4_addresses.join(",")), + ("ipv6_addresses".to_string(), m.ipv6_addresses.join(",")), + ]), + record_id: String::new(), + }; + match grpc::send_batch(client, format!("metrics-{}", batch_id()), vec![record]).await { + Ok(_) => tracing::debug!(host, "metrics sent"), + Err(e) => tracing::warn!(error = %e, host, "metrics send failed"), + } +} + fn now_unix_nanos() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/agent/sentry-agent/src/metrics.rs b/agent/sentry-agent/src/metrics.rs new file mode 100644 index 0000000..86f1a10 --- /dev/null +++ b/agent/sentry-agent/src/metrics.rs @@ -0,0 +1,394 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::process::Command; +use std::time::Duration; + +pub struct Metrics { + pub cpu_percent: f64, + pub mem_used_bytes: u64, + pub mem_total_bytes: u64, + pub disk_used_bytes: u64, + pub disk_total_bytes: u64, + /// The rest of these are static-or-slow-changing context, not + /// utilization numbers -- sent alongside the utilization fields on + /// the same record (rather than as a separate one-off record) so a + /// viewer never has to correlate two different samples to answer + /// "is 21% CPU busy or idle for this box" (needs core count) or + /// "is this usage normal" (needs how long it's been running). + pub cpu_cores: u32, + pub os_name: String, + pub kernel_version: String, + pub arch: &'static str, + pub uptime_seconds: u64, + /// Non-loopback, non-link-local addresses only -- a host's `fe80::/10` + /// and `127.0.0.1`/`::1` are never what a viewer means by "this + /// host's IP", and would just add noise. Sorted and deduplicated, + /// but otherwise unfiltered: a multi-NIC host reports every address + /// it has, not just one "primary" guess (there's no reliable way to + /// pick a single "the" address from userspace without also knowing + /// which interface actually carries this host's traffic). + pub ipv4_addresses: Vec, + pub ipv6_addresses: Vec, +} + +/// Collects a point-in-time snapshot of host resource usage plus the +/// system context needed to make it legible. Linux-only for now (a +/// disclosed gap, not a silent assumption -- see /agent/README.md): +/// every host this has been deployed to so far is Linux, and a Windows +/// implementation (perf counters/WMI) is real future work, not +/// attempted here. +pub async fn collect(disk_path: &str) -> Result { + let cpu_percent = cpu_percent().await.context("reading CPU usage")?; + let (mem_used_bytes, mem_total_bytes) = memory().context("reading memory usage")?; + let (disk_used_bytes, disk_total_bytes) = disk(disk_path).context("reading disk usage")?; + // Soft-fail on all four: none of these should ever cost a whole + // sample (losing real cpu_percent/mem/disk numbers) just because + // e.g. /etc/os-release is missing on some minimal distro -- + // consistent with this codebase's existing "an optional feature's + // failure must never take down the thing it's supplementing" + // posture (see send_heartbeat/CheckIn's own graceful degradation). + let cpu_cores = cpu_cores().unwrap_or(0); + let os_name = os_name().unwrap_or_else(|_| "unknown".to_string()); + let kernel_version = kernel_version().unwrap_or_else(|_| "unknown".to_string()); + let uptime_seconds = uptime_seconds().unwrap_or(0); + let (ipv4_addresses, ipv6_addresses) = ip_addresses().unwrap_or_default(); + Ok(Metrics { + cpu_percent, + mem_used_bytes, + mem_total_bytes, + disk_used_bytes, + disk_total_bytes, + cpu_cores, + os_name, + kernel_version, + arch: std::env::consts::ARCH, + uptime_seconds, + ipv4_addresses, + ipv6_addresses, + }) +} + +/// Two `/proc/stat` samples ~200ms apart, delta-based -- the standard +/// technique every `top`-like tool uses, since a single snapshot of +/// cumulative jiffies-since-boot can't express a percentage on its own. +/// Self-contained (no state threaded through main.rs's `select!` loop) +/// at the cost of blocking this one `collect()` call for ~200ms once +/// per `metrics.interval` tick -- an acceptable trade against the +/// complexity of holding a previous-sample struct across ticks in an +/// already-busy loop, for a feature that only runs once a minute by +/// default. +async fn cpu_percent() -> Result { + let (total1, idle1) = read_proc_stat()?; + tokio::time::sleep(Duration::from_millis(200)).await; + let (total2, idle2) = read_proc_stat()?; + + let total_delta = total2.saturating_sub(total1); + let idle_delta = idle2.saturating_sub(idle1); + if total_delta == 0 { + return Ok(0.0); + } + Ok((1.0 - (idle_delta as f64 / total_delta as f64)) * 100.0) +} + +fn read_proc_stat() -> Result<(u64, u64)> { + let contents = fs::read_to_string("/proc/stat").context("reading /proc/stat")?; + parse_proc_stat(&contents) +} + +/// Parses `/proc/stat`'s leading "cpu " line: user nice system idle +/// iowait irq softirq steal guest guest_nice, all in USER_HZ jiffies +/// since boot. Returns (total, idle) -- idle here is idle+iowait, +/// matching what every standard CPU%-from-/proc/stat implementation +/// treats as "not busy" (iowait is a CPU waiting on I/O, not doing +/// work, even though the kernel's own `idle` field alone doesn't +/// include it). +fn parse_proc_stat(contents: &str) -> Result<(u64, u64)> { + let line = contents + .lines() + .find(|l| l.starts_with("cpu ")) + .context("/proc/stat has no leading \"cpu \" line")?; + let fields: Vec = line.split_whitespace().skip(1).filter_map(|f| f.parse().ok()).collect(); + if fields.len() < 4 { + anyhow::bail!("unexpected /proc/stat format: {line:?}"); + } + let idle = fields[3] + fields.get(4).copied().unwrap_or(0); + let total: u64 = fields.iter().sum(); + Ok((total, idle)) +} + +fn memory() -> Result<(u64, u64)> { + let contents = fs::read_to_string("/proc/meminfo").context("reading /proc/meminfo")?; + parse_meminfo(&contents) +} + +/// Parses `/proc/meminfo`'s MemTotal/MemAvailable (kB). MemAvailable +/// (not MemFree) is the kernel's own "how much could a new process +/// actually get" estimate, accounting for reclaimable caches/buffers -- +/// what a human means by "memory used" far better than MemFree alone +/// (a system with most of RAM in disk cache but MemFree near zero is +/// not actually under memory pressure). +fn parse_meminfo(contents: &str) -> Result<(u64, u64)> { + let mut total_kb = None; + let mut available_kb = None; + for line in contents.lines() { + if let Some(v) = line.strip_prefix("MemTotal:") { + total_kb = parse_meminfo_kb(v); + } else if let Some(v) = line.strip_prefix("MemAvailable:") { + available_kb = parse_meminfo_kb(v); + } + } + let total_kb = total_kb.context("MemTotal not found in /proc/meminfo")?; + let available_kb = available_kb.context("MemAvailable not found in /proc/meminfo")?; + let used_kb = total_kb.saturating_sub(available_kb); + Ok((used_kb * 1024, total_kb * 1024)) +} + +fn parse_meminfo_kb(s: &str) -> Option { + s.trim().trim_end_matches("kB").trim().parse().ok() +} + +fn disk(path: &str) -> Result<(u64, u64)> { + let output = Command::new("df").arg("-B1").arg(path).output().context("running df")?; + if !output.status.success() { + anyhow::bail!("df exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr)); + } + parse_df_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// Shells out to `df` rather than linking a statvfs binding -- same +/// "shell out to a boring, ubiquitous tool rather than add a dependency +/// or FFI binding" precedent `source/journald.rs` already sets for +/// `journalctl` (see /agent/README.md's "Why journalctl, not +/// libsystemd"). `-B1` requests byte-granularity output instead of the +/// default 1K-block units, so no unit conversion is needed here. Total +/// is `used + available`, not the raw block count `df` also reports -- +/// some filesystems (ext4's default ~5% root reservation) hold back +/// blocks a normal process can never use, which would make a "percent +/// full" computed against the raw total look artificially low; `used + +/// available` matches what `df`'s own `Use%` column is computed +/// against. +fn parse_df_output(stdout: &str) -> Result<(u64, u64)> { + let data_line = stdout.lines().nth(1).context("df produced no data line")?; + let fields: Vec<&str> = data_line.split_whitespace().collect(); + // Filesystem, 1B-blocks, Used, Available, Use%, Mounted on + if fields.len() < 4 { + anyhow::bail!("unexpected df output: {data_line:?}"); + } + let used: u64 = fields[2].parse().context("parsing df's Used column")?; + let available: u64 = fields[3].parse().context("parsing df's Available column")?; + Ok((used, used + available)) +} + +fn cpu_cores() -> Result { + let contents = fs::read_to_string("/proc/cpuinfo").context("reading /proc/cpuinfo")?; + parse_cpuinfo_core_count(&contents) +} + +/// Counts `processor\t: N` lines in `/proc/cpuinfo` -- one per logical +/// CPU (a hyperthreaded core counts as two, same as what `nproc`/every +/// scheduler-facing tool means by "CPU count"), which is what +/// `cpu_percent`'s 0-100 scale is an average across. +fn parse_cpuinfo_core_count(contents: &str) -> Result { + let n = contents.lines().filter(|l| l.starts_with("processor")).count() as u32; + if n == 0 { + anyhow::bail!("no \"processor\" lines found in /proc/cpuinfo"); + } + Ok(n) +} + +fn os_name() -> Result { + let contents = fs::read_to_string("/etc/os-release").context("reading /etc/os-release")?; + parse_os_release_pretty_name(&contents) +} + +/// Parses `/etc/os-release`'s `PRETTY_NAME="..."` line (e.g. "Debian +/// GNU/Linux 13 (trixie)") -- the one field every distro's os-release +/// is guaranteed to carry for exactly this "show a human a readable OS +/// name" purpose (see os-release(5)). +fn parse_os_release_pretty_name(contents: &str) -> Result { + contents + .lines() + .find_map(|l| l.strip_prefix("PRETTY_NAME=")) + .map(|v| v.trim().trim_matches('"').to_string()) + .context("PRETTY_NAME not found in /etc/os-release") +} + +/// `/proc/sys/kernel/osrelease` is just the bare version string (e.g. +/// "6.12.90+deb13.1-amd64") with no parsing needed -- simpler and more +/// robust than picking the version back out of `/proc/version`'s +/// free-form `uname -a`-style sentence. +fn kernel_version() -> Result { + Ok(fs::read_to_string("/proc/sys/kernel/osrelease") + .context("reading /proc/sys/kernel/osrelease")? + .trim() + .to_string()) +} + +fn uptime_seconds() -> Result { + let contents = fs::read_to_string("/proc/uptime").context("reading /proc/uptime")?; + parse_uptime(&contents) +} + +/// `/proc/uptime`'s first field is seconds since boot (as a float, to +/// centisecond precision) -- the second field (total idle time summed +/// across all cores) isn't relevant here. +fn parse_uptime(contents: &str) -> Result { + let first = contents.split_whitespace().next().context("/proc/uptime is empty")?; + let seconds: f64 = first.parse().context("parsing /proc/uptime's first field")?; + Ok(seconds as u64) +} + +fn ip_addresses() -> Result<(Vec, Vec)> { + let output = Command::new("ip").arg("-o").arg("addr").arg("show").output().context("running ip addr show")?; + if !output.status.success() { + anyhow::bail!("ip exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr)); + } + Ok(parse_ip_addr_output(&String::from_utf8_lossy(&output.stdout))) +} + +/// Shells out to `ip -o addr show` -- same "boring, ubiquitous tool" +/// precedent `disk`'s `df` call and `source/journald.rs`'s `journalctl` +/// call already set, over an FFI binding to `getifaddrs(3)`. `-o` +/// (oneline) puts each address on its own line, e.g.: +/// 2: eth0 inet 172.239.44.244/24 brd ... scope global eth0\ ... +/// 2: eth0 inet6 fe80::1/64 scope link \ ... +/// Skips the loopback interface by name (`lo`) and any address whose +/// line mentions `scope link` (IPv6 link-local, `fe80::/10`) or +/// `scope host` (loopback addresses `ip` sometimes reports even on a +/// non-`lo` line) -- neither is what a viewer means by "this host's +/// IP". Field 1 is the interface name, field 2 is the address family +/// (`inet`/`inet6`), field 3 is `address/prefix-length`. +fn parse_ip_addr_output(stdout: &str) -> (Vec, Vec) { + let mut v4 = Vec::new(); + let mut v6 = Vec::new(); + for line in stdout.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 4 { + continue; + } + let iface = fields[1]; + if iface == "lo" || line.contains("scope link") || line.contains("scope host") { + continue; + } + let addr = fields[3].split('/').next().unwrap_or(fields[3]); + match fields[2] { + "inet" => v4.push(addr.to_string()), + "inet6" => v6.push(addr.to_string()), + _ => {} + } + } + v4.sort(); + v4.dedup(); + v6.sort(); + v6.dedup(); + (v4, v6) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_proc_stat() { + let contents = "cpu 100 0 50 800 20 0 0 0 0 0\ncpu0 100 0 50 800 20 0 0 0 0 0\n"; + let (total, idle) = parse_proc_stat(contents).unwrap(); + // total = 100+0+50+800+20 = 970; idle = 800 (idle) + 20 (iowait) = 820 + assert_eq!(total, 970); + assert_eq!(idle, 820); + } + + #[test] + fn rejects_proc_stat_with_no_cpu_line() { + assert!(parse_proc_stat("not cpu data\n").is_err()); + } + + #[test] + fn parses_meminfo() { + let contents = "MemTotal: 16384000 kB\nMemFree: 1000000 kB\nMemAvailable: 8192000 kB\n"; + let (used, total) = parse_meminfo(contents).unwrap(); + assert_eq!(total, 16384000 * 1024); + assert_eq!(used, (16384000 - 8192000) * 1024); + } + + #[test] + fn rejects_meminfo_missing_fields() { + assert!(parse_meminfo("MemTotal: 16384000 kB\n").is_err()); + } + + #[test] + fn parses_df_output() { + let stdout = "Filesystem 1B-blocks Used Available Use% Mounted on\n/dev/sda1 80000000000 20000000000 60000000000 25% /\n"; + let (used, total) = parse_df_output(stdout).unwrap(); + assert_eq!(used, 20000000000); + assert_eq!(total, 20000000000 + 60000000000); + } + + #[test] + fn rejects_df_output_with_no_data_line() { + assert!(parse_df_output("Filesystem 1B-blocks Used Available Use% Mounted on\n").is_err()); + } + + #[test] + fn counts_cpuinfo_processors() { + let contents = "processor\t: 0\nmodel name\t: x\n\nprocessor\t: 1\nmodel name\t: x\n"; + assert_eq!(parse_cpuinfo_core_count(contents).unwrap(), 2); + } + + #[test] + fn rejects_cpuinfo_with_no_processor_lines() { + assert!(parse_cpuinfo_core_count("model name: x\n").is_err()); + } + + #[test] + fn parses_os_release_pretty_name() { + let contents = "NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nVERSION_ID=\"13\"\n"; + assert_eq!(parse_os_release_pretty_name(contents).unwrap(), "Debian GNU/Linux 13 (trixie)"); + } + + #[test] + fn rejects_os_release_missing_pretty_name() { + assert!(parse_os_release_pretty_name("NAME=\"Debian\"\n").is_err()); + } + + #[test] + fn parses_uptime() { + assert_eq!(parse_uptime("12345.67 98765.43\n").unwrap(), 12345); + } + + #[test] + fn rejects_empty_uptime() { + assert!(parse_uptime("").is_err()); + } + + #[test] + fn parses_ip_addr_output_excluding_loopback_and_link_local() { + let stdout = concat!( + "1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n", + "1: lo inet6 ::1/128 scope host \\ valid_lft forever preferred_lft forever\n", + "2: eth0 inet 172.239.44.244/24 brd 172.239.44.255 scope global eth0\\ valid_lft forever preferred_lft forever\n", + "2: eth0 inet6 2600:3c06::1/64 scope global dynamic mngtmpaddr noprefixroute \\ valid_lft forever preferred_lft forever\n", + "2: eth0 inet6 fe80::abcd/64 scope link \\ valid_lft forever preferred_lft forever\n", + ); + let (v4, v6) = parse_ip_addr_output(stdout); + assert_eq!(v4, vec!["172.239.44.244".to_string()]); + assert_eq!(v6, vec!["2600:3c06::1".to_string()]); + } + + #[test] + fn parses_ip_addr_output_dedupes_and_sorts_multiple_interfaces() { + let stdout = concat!( + "2: eth0 inet 10.0.0.5/24 scope global eth0\\ valid_lft forever preferred_lft forever\n", + "3: eth1 inet 10.0.0.2/24 scope global eth1\\ valid_lft forever preferred_lft forever\n", + "3: eth1 inet 10.0.0.5/24 scope global secondary eth1\\ valid_lft forever preferred_lft forever\n", + ); + let (v4, _v6) = parse_ip_addr_output(stdout); + assert_eq!(v4, vec!["10.0.0.2".to_string(), "10.0.0.5".to_string()]); + } + + #[test] + fn parses_ip_addr_output_with_no_addresses() { + let (v4, v6) = parse_ip_addr_output("1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n"); + assert!(v4.is_empty()); + assert!(v6.is_empty()); + } +} diff --git a/alerting/cmd/alerting/main.go b/alerting/cmd/alerting/main.go index 228a391..3cbb19e 100644 --- a/alerting/cmd/alerting/main.go +++ b/alerting/cmd/alerting/main.go @@ -29,6 +29,7 @@ import ( "github.com/sentry/sentry/alerting/internal/notifystore" "github.com/sentry/sentry/alerting/internal/queryclient" "github.com/sentry/sentry/alerting/internal/rulestore" + "github.com/sentry/sentry/alerting/internal/sessioncheck" ) func main() { @@ -39,6 +40,9 @@ func main() { logger.Error("loading config", "error", err) os.Exit(1) } + for _, w := range cfg.DevCredentialWarnings() { + logger.Warn(w) + } // -healthcheck: self-check mode for Docker's HEALTHCHECK, mirrors // api/cmd/api/main.go's runHealthcheck -- this image is distroless too @@ -71,9 +75,23 @@ func main() { handler := httpapi.NewHandler(logger, rules, targets, rules) mux := http.NewServeMux() handler.RegisterRoutes(mux) + + // Local login (see /docs -- deployment runbook, and + // api/localauth's package doc comment for the full feature): + // alerting has no per-route role plumbing of its own, so this is one + // blanket "must have a valid session" gate in front of the whole + // mux, same shape CORS already wraps it in below. /healthz stays + // reachable regardless -- see sessioncheck.RequireSession's doc + // comment. + var gatedMux http.Handler = mux + corsFn := httpserver.WithCORS + if cfg.LocalAuthEnabled { + gatedMux = sessioncheck.RequireSession(sessioncheck.NewChecker(pgPool), mux) + corsFn = httpserver.WithCredentialedCORS + } srv := &http.Server{ Addr: cfg.HTTPListenAddr, - Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin), + Handler: corsFn(gatedMux, cfg.CORSAllowedOrigin), } eval := evaluator.New(rules, targets, qc, cfg.Evaluator.QueryTimeout, cfg.Evaluator.ClaimBatchSize, cfg.Evaluator.WorkerPoolSize, logger) diff --git a/alerting/go.mod b/alerting/go.mod index 082b3ae..aa3faaa 100644 --- a/alerting/go.mod +++ b/alerting/go.mod @@ -12,5 +12,5 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/text v0.39.0 // indirect ) diff --git a/alerting/go.sum b/alerting/go.sum index 00ecaf7..183740e 100644 --- a/alerting/go.sum +++ b/alerting/go.sum @@ -20,8 +20,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/alerting/internal/config/config.go b/alerting/internal/config/config.go index 49032da..c53ab1a 100644 --- a/alerting/internal/config/config.go +++ b/alerting/internal/config/config.go @@ -16,6 +16,11 @@ type Config struct { APIServiceToken string // RoleService credential presented to /api's POST /query -- see queryclient.New's doc comment CORSAllowedOrigin string Evaluator EvaluatorConfig + // LocalAuthEnabled gates alerting's own session-required middleware + // (see internal/sessioncheck) -- same env var name as api's + // LOCAL_AUTH_ENABLED, one consistent on/off switch across both + // services for a single-tenant deployment turning local login on. + LocalAuthEnabled bool } type PostgresConfig struct { @@ -43,6 +48,25 @@ type EvaluatorConfig struct { QueryTimeout time.Duration // per-evaluation POST /query timeout } +// devOnlyCredential is docker-compose.yml's zero-config default for +// every Postgres/ClickHouse password in this repo -- see +// api/internal/config.Config.DevCredentialWarnings for the full +// reasoning (duplicated here per this repo's no-shared-code-between- +// services convention). +const devOnlyCredential = "sentry-dev-only" + +// DevCredentialWarnings reports whether the configured Postgres +// credential still equals the literal dev-only default -- +// cmd/alerting/main.go logs it loudly at startup. A warning, not a +// startup-refusing error: local dev's zero-config docker-compose.yml +// path legitimately leaves it at this value. +func (c Config) DevCredentialWarnings() []string { + if c.Postgres.Password == devOnlyCredential { + return []string{"POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev"} + } + return nil +} + func Load() (Config, error) { cfg := Config{ HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"), @@ -90,6 +114,12 @@ func Load() (Config, error) { } cfg.Evaluator.QueryTimeout = time.Duration(queryTimeoutSec) * time.Second + localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false")) + if err != nil { + return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err) + } + cfg.LocalAuthEnabled = localAuthEnabled + return cfg, nil } diff --git a/alerting/internal/delivery/webhook.go b/alerting/internal/delivery/webhook.go index 4107da9..7031532 100644 --- a/alerting/internal/delivery/webhook.go +++ b/alerting/internal/delivery/webhook.go @@ -162,6 +162,17 @@ func (w *Worker) attempt(ctx context.Context, c claimedDelivery) { return } + // Re-validated here, not just at target-creation time + // (httpapi.handleCreateTarget already checks this too): a hostname + // that resolved to a public IP when the target was created can be + // repointed at an internal/metadata address later via DNS rebinding, + // and this is the point that actually issues the outbound request -- + // see notifystore.ValidateWebhookURL's doc comment. + if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil { + w.fail(ctx, c, 0, fmt.Sprintf("webhook_url no longer valid: %v", err)) + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL, bytes.NewReader(c.payload)) if err != nil { w.fail(ctx, c, 0, fmt.Sprintf("building request: %v", err)) diff --git a/alerting/internal/httpapi/handler.go b/alerting/internal/httpapi/handler.go index 3497c5e..f88b5cd 100644 --- a/alerting/internal/httpapi/handler.go +++ b/alerting/internal/httpapi/handler.go @@ -151,6 +151,10 @@ func (h *Handler) handleCreateTarget(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "webhook_url must not be empty") return } + if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil { + writeError(w, http.StatusBadRequest, "webhook_url: "+err.Error()) + return + } if err := h.targets.Create(r.Context(), &target); err != nil { h.logger.Error("creating notification target", "error", err) writeError(w, http.StatusInternalServerError, "creating notification target failed") diff --git a/alerting/internal/httpapi/handler_test.go b/alerting/internal/httpapi/handler_test.go index 74efb23..d99e7b8 100644 --- a/alerting/internal/httpapi/handler_test.go +++ b/alerting/internal/httpapi/handler_test.go @@ -231,12 +231,29 @@ func TestCreateTargetRejectsInvalidKind(t *testing.T) { func TestCreateSlackTarget(t *testing.T) { mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{}) - rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://hooks.slack.com/services/x"}`) + // A literal public IP, not a real hostname like hooks.slack.com -- + // ValidateWebhookURL (see notifystore/ssrf.go) now resolves the + // target host and rejects internal/metadata addresses, so this test + // stays deterministic without depending on live DNS; ssrf_test.go + // covers the validation logic itself in depth. + rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://8.8.8.8/services/x"}`) if rec.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) } } +// TestCreateTargetRejectsSSRFWebhookURL is the regression test for the +// security-audit finding that target creation performed no URL +// validation at all -- any authenticated user could point a webhook at +// an internal or cloud-metadata address. +func TestCreateTargetRejectsSSRFWebhookURL(t *testing.T) { + mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{}) + rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "x", "kind": "webhook", "webhook_url": "http://169.254.169.254/latest/meta-data/"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + func TestListDeliveriesForRule(t *testing.T) { deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{ {ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"}, diff --git a/alerting/internal/httpserver/cors.go b/alerting/internal/httpserver/cors.go index cfaef75..01bc82e 100644 --- a/alerting/internal/httpserver/cors.go +++ b/alerting/internal/httpserver/cors.go @@ -22,3 +22,27 @@ func WithCORS(next http.Handler, allowedOrigin string) http.Handler { next.ServeHTTP(w, r) }) } + +// WithCredentialedCORS is WithCORS's sibling for local login (see +// /docs -- deployment runbook): once alerting requires a session +// cookie/bearer token (sessioncheck.RequireSession), a browser calling +// it cross-origin must send credentials, and browsers categorically +// refuse to combine a credentialed request with +// Access-Control-Allow-Origin: "*" -- allowedOrigin must be a real, +// literal origin, not the wildcard WithCORS's own zero-config default +// relies on. Deliberately duplicated from api/httpserver's identical +// function rather than shared, same convention as WithCORS's own doc +// comment above. +func WithCredentialedCORS(next http.Handler, allowedOrigin string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/alerting/internal/notifystore/ssrf.go b/alerting/internal/notifystore/ssrf.go new file mode 100644 index 0000000..cd47185 --- /dev/null +++ b/alerting/internal/notifystore/ssrf.go @@ -0,0 +1,64 @@ +package notifystore + +import ( + "fmt" + "net" + "net/url" +) + +// ValidateWebhookURL rejects a notification target URL that resolves to +// an internal, loopback, link-local, or cloud-metadata address -- +// closes an SSRF path a security audit found: without this, any user +// who could create a notification target could point +// delivery.Worker.attempt's outbound POST at +// http://169.254.169.254/... or an internal service address, and read +// back what happened via delivery_log's recorded status code -- a +// semi-blind SSRF oracle. Applies to all three Kind values (webhook, +// slack, pagerduty), since all three deliver through the same +// WebhookURL-addressed POST -- see webhook.go's package doc comment. +// +// Callers should invoke this both at target-creation time +// (httpapi.handleCreateTarget) and again immediately before every +// delivery attempt (delivery.Worker.attempt): a hostname that resolved +// to a public IP at creation time can be repointed at an internal one +// later (DNS rebinding), so checking only once would leave that gap +// open. +func ValidateWebhookURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL scheme must be http or https, got %q", u.Scheme) + } + host := u.Hostname() + if host == "" { + return fmt.Errorf("URL must have a host") + } + + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("resolving host %q: %w", host, err) + } + if len(ips) == 0 { + return fmt.Errorf("host %q did not resolve to any address", host) + } + for _, ip := range ips { + if isDisallowedWebhookTarget(ip) { + return fmt.Errorf("host %q resolves to %s, a disallowed address -- internal, loopback, link-local, and cloud-metadata addresses are not allowed as notification target URLs", host, ip) + } + } + return nil +} + +// isDisallowedWebhookTarget covers RFC1918/RFC4193 private ranges, +// loopback, link-local (which also covers 169.254.169.254, the AWS/GCP/ +// Azure instance-metadata address), unspecified, and multicast. +func isDisallowedWebhookTarget(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsPrivate() || + ip.IsUnspecified() || + ip.IsMulticast() +} diff --git a/alerting/internal/notifystore/ssrf_test.go b/alerting/internal/notifystore/ssrf_test.go new file mode 100644 index 0000000..f85d9cb --- /dev/null +++ b/alerting/internal/notifystore/ssrf_test.go @@ -0,0 +1,48 @@ +package notifystore + +import "testing" + +// Uses literal IP addresses throughout, not real hostnames -- net.LookupIP +// resolves a literal IP without a network round trip, so these tests stay +// deterministic and fast in any environment, including one with no DNS/ +// network access. +func TestValidateWebhookURLRejectsInternalAndMetadataAddresses(t *testing.T) { + disallowed := []string{ + "http://169.254.169.254/latest/meta-data/", // cloud instance metadata + "http://127.0.0.1:8123/", // loopback -- e.g. ClickHouse + "http://10.0.0.5:5432/", // RFC1918 + "http://172.17.0.2:9092/", // RFC1918 (default docker bridge range) + "http://192.168.1.1/", // RFC1918 + "http://[::1]/", // IPv6 loopback + "http://[fe80::1]/", // IPv6 link-local + "http://[fc00::1]/", // IPv6 unique local (RFC4193) + "http://0.0.0.0/", // unspecified + } + for _, u := range disallowed { + if err := ValidateWebhookURL(u); err == nil { + t.Errorf("ValidateWebhookURL(%q): want error, got nil", u) + } + } +} + +func TestValidateWebhookURLAllowsPublicAddress(t *testing.T) { + // A real-looking public IP literal, not a hostname needing DNS. + if err := ValidateWebhookURL("https://8.8.8.8/webhook"); err != nil { + t.Errorf("ValidateWebhookURL on a public IP: err = %v, want nil", err) + } +} + +func TestValidateWebhookURLRejectsBadScheme(t *testing.T) { + cases := []string{"ftp://8.8.8.8/", "file:///etc/passwd", "not-a-url"} + for _, u := range cases { + if err := ValidateWebhookURL(u); err == nil { + t.Errorf("ValidateWebhookURL(%q): want error, got nil", u) + } + } +} + +func TestValidateWebhookURLRejectsEmptyHost(t *testing.T) { + if err := ValidateWebhookURL("http:///path"); err == nil { + t.Error("ValidateWebhookURL with no host: want error, got nil") + } +} diff --git a/alerting/internal/sessioncheck/middleware.go b/alerting/internal/sessioncheck/middleware.go new file mode 100644 index 0000000..3abc7b3 --- /dev/null +++ b/alerting/internal/sessioncheck/middleware.go @@ -0,0 +1,91 @@ +package sessioncheck + +import ( + "encoding/json" + "net/http" +) + +// sessionCookieName must match api/localauth's sessionCookieName +// exactly (unexported there too, deliberately duplicated rather than +// imported -- see this package's doc comment) -- the same cookie +// api/localauth.Handler.setCookie writes, scoped (via SESSION_COOKIE_ +// DOMAIN) to cover both api's and alerting's subdomains in production. +const sessionCookieName = "sentry_local_session" + +type errorResponse struct { + Error string `json:"error"` +} + +func writeUnauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(errorResponse{Error: "unauthorized"}) +} + +func writeForbidden(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(errorResponse{Error: "forbidden"}) +} + +// mutatingRoleFloor is the minimum role RequireSession enforces for any +// non-read request -- closes a real gap the security audit found: this +// package used to be a pure "logged in or not" gate with no role check +// at all, meaning a Viewer-role session could create/delete alert rules +// and notification targets exactly like an Editor. GET/HEAD (read-only) +// stay at "any valid session," matching every role floor in this +// codebase's other RBAC-gated resources (queries, dashboards) using +// Viewer as their read bar. +const mutatingRoleFloor = "editor" + +func isReadOnly(method string) bool { + return method == http.MethodGet || method == http.MethodHead +} + +func credentialFromRequest(r *http.Request) string { + if auth := r.Header.Get("Authorization"); auth != "" { + const prefix = "Bearer " + if len(auth) > len(prefix) && auth[:len(prefix)] == prefix { + return auth[len(prefix):] + } + } + if cookie, err := r.Cookie(sessionCookieName); err == nil { + return cookie.Value + } + return "" +} + +// RequireSession wraps next so every request needs a valid local-login +// session -- a blanket gate, not per-route roles: alerting has no +// role-check plumbing at all today (unlike api/authz's per-route +// RequireRole), and building a full parallel system just for this +// feature is out of scope (see /docs/agent-management-design.md-style +// "resist scope creep" discipline this codebase applies everywhere). +// GET /healthz is deliberately exempt -- Docker's HEALTHCHECK execs +// this same binary against itself over loopback (cmd/alerting/main.go's +// runHealthcheck), pre-auth, and must keep working regardless of +// whether local auth is enabled. +func RequireSession(checker *Checker, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + + raw := credentialFromRequest(r) + if raw == "" { + writeUnauthorized(w) + return + } + role, err := checker.Validate(r.Context(), raw) + if err != nil { + writeUnauthorized(w) + return + } + if !isReadOnly(r.Method) && !roleSatisfies(role, mutatingRoleFloor) { + writeForbidden(w) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/alerting/internal/sessioncheck/sessioncheck.go b/alerting/internal/sessioncheck/sessioncheck.go new file mode 100644 index 0000000..4ade22c --- /dev/null +++ b/alerting/internal/sessioncheck/sessioncheck.go @@ -0,0 +1,73 @@ +// Package sessioncheck is alerting's half of local login (see +// api/localauth's package doc comment for the full feature). It only +// ever validates an already-issued session against the shared +// local_sessions table api/localauth writes to (same Postgres, no Go +// import) -- it never handles a raw password, never creates a session, +// and has no user-management surface at all; that stays exclusively in +// api. Deliberately its own small package rather than an import of +// api/localauth: this repo's hard, documented convention is no shared +// Go store/HTTP code between api and alerting, only /proto (see +// alerting/internal/httpserver/cors.go's WithCORS doc comment) -- +// duplicating this one hash-and-look-up check is a small, low-risk +// price for keeping that boundary real. +package sessioncheck + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrInvalidSession = errors.New("sessioncheck: invalid or expired session") + +type Checker struct { + pool *pgxpool.Pool +} + +func NewChecker(pool *pgxpool.Pool) *Checker { + return &Checker{pool: pool} +} + +// roleRank duplicates api/authz.Role's rank table -- same "no shared Go +// code between api and alerting" boundary this package's doc comment +// already explains for hashToken, applied to the one extra column +// (role) middleware.go now needs to enforce a floor on mutating +// requests (see RequireSession). +var roleRank = map[string]int{"viewer": 1, "editor": 2, "admin": 3, "owner": 4} + +// roleSatisfies reports whether role meets minRole on the same +// Viewer= roleRank[minRole] +} + +// Validate hashes raw (plain SHA-256, no bcrypt -- see +// api/localauth/token.go's hashToken doc comment for why a session +// token doesn't need bcrypt's deliberate slowness) and checks it +// against local_sessions, returning the session's role snapshot +// alongside. Returns ErrInvalidSession for both "no such session" and +// "expired" -- middleware.go's caller doesn't distinguish them either, +// same posture api/localauth.Store.GetSession already takes for the +// same two cases. +func (c *Checker) Validate(ctx context.Context, raw string) (role string, err error) { + sum := sha256.Sum256([]byte(raw)) + hash := hex.EncodeToString(sum[:]) + + var expiresAt time.Time + err = c.pool.QueryRow(ctx, `SELECT role, expires_at FROM local_sessions WHERE token_hash = $1`, hash).Scan(&role, &expiresAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrInvalidSession + } + return "", err + } + if expiresAt.Before(time.Now()) { + return "", ErrInvalidSession + } + return role, nil +} diff --git a/alerting/internal/sessioncheck/sessioncheck_integration_test.go b/alerting/internal/sessioncheck/sessioncheck_integration_test.go new file mode 100644 index 0000000..575cc2c --- /dev/null +++ b/alerting/internal/sessioncheck/sessioncheck_integration_test.go @@ -0,0 +1,182 @@ +// Exercises Checker.Validate against a real local_sessions row -- +// unlike a fake, this confirms alerting can actually read the rows +// api/localauth (a separate Go module/service) writes into the shared +// Postgres, including the exact hash function agreeing on both sides. +// Same "skip unless a live-Postgres env var is set" convention as +// api/dashboards/store_integration_test.go. +// +// Skipped unless SESSIONCHECK_TEST_POSTGRES_ADDR is set; run via: +// +// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/alerting \ +// -e SESSIONCHECK_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ +// -e SESSIONCHECK_TEST_POSTGRES_PASSWORD=sentry-dev-only \ +// golang:1.25-alpine go test ./internal/sessioncheck/... -run Integration -v +package sessioncheck + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func integrationPool(t *testing.T) *pgxpool.Pool { + t.Helper() + addr := os.Getenv("SESSIONCHECK_TEST_POSTGRES_ADDR") + if addr == "" { + t.Skip("SESSIONCHECK_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") + } + password := os.Getenv("SESSIONCHECK_TEST_POSTGRES_PASSWORD") + dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("opening pool: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +// insertTestSession writes directly into local_sessions and users -- +// this package has no Store type of its own (see package doc comment: +// creating a session is api/localauth's job, this only ever validates +// one), so a real row has to come from somewhere for the test to check +// against. +func insertTestSession(t *testing.T, pool *pgxpool.Pool, ttl time.Duration) (raw string) { + t.Helper() + return insertTestSessionWithRole(t, pool, ttl, "viewer") +} + +func insertTestSessionWithRole(t *testing.T, pool *pgxpool.Pool, ttl time.Duration, role string) (raw string) { + t.Helper() + ctx := context.Background() + + userID := uuid.NewString() + if _, err := pool.Exec(ctx, ` + INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at) + VALUES ($1, $2, 'unused', $2, now(), now())`, + userID, "test-"+userID[:8]); err != nil { + t.Fatalf("inserting test user: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) + + buf := make([]byte, 32) + sum := sha256.Sum256([]byte(userID + role)) // deterministic-enough per-test randomness without crypto/rand here + copy(buf, sum[:]) + raw = base64.RawURLEncoding.EncodeToString(buf) + hashSum := sha256.Sum256([]byte(raw)) + hash := hex.EncodeToString(hashSum[:]) + + if _, err := pool.Exec(ctx, ` + INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at) + VALUES ($1, $2, 'default', $3, $4, $5)`, + uuid.NewString(), userID, role, hash, time.Now().Add(ttl)); err != nil { + t.Fatalf("inserting test session: %v", err) + } + return raw +} + +func TestIntegrationValidateAcceptsRealSession(t *testing.T) { + pool := integrationPool(t) + raw := insertTestSession(t, pool, time.Hour) + + role, err := NewChecker(pool).Validate(context.Background(), raw) + if err != nil { + t.Errorf("Validate on a real, unexpired session: err = %v, want nil", err) + } + if role != "viewer" { + t.Errorf("role = %q, want %q (matches insertTestSession's role column)", role, "viewer") + } +} + +func TestIntegrationValidateRejectsExpiredSession(t *testing.T) { + pool := integrationPool(t) + raw := insertTestSession(t, pool, -time.Hour) + + if _, err := NewChecker(pool).Validate(context.Background(), raw); !errors.Is(err, ErrInvalidSession) { + t.Errorf("Validate on an expired session: err = %v, want ErrInvalidSession", err) + } +} + +func TestIntegrationValidateRejectsUnknownToken(t *testing.T) { + pool := integrationPool(t) + + if _, err := NewChecker(pool).Validate(context.Background(), "not-a-real-token"); !errors.Is(err, ErrInvalidSession) { + t.Errorf("Validate on an unknown token: err = %v, want ErrInvalidSession", err) + } +} + +// TestIntegrationRequireSessionForbidsMutatingRequestFromViewer is the +// regression test for the security-audit finding that this middleware +// used to be a pure "logged in or not" gate: a Viewer-role session +// could create/delete alert rules and notification targets exactly like +// an Editor. A POST from a Viewer session must now be 403, not passed +// through to the handler. +func TestIntegrationRequireSessionForbidsMutatingRequestFromViewer(t *testing.T) { + pool := integrationPool(t) + raw := insertTestSessionWithRole(t, pool, time.Hour, "viewer") + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true; w.WriteHeader(http.StatusOK) }) + handler := RequireSession(NewChecker(pool), next) + + req := httptest.NewRequest(http.MethodPost, "/targets", nil) + req.Header.Set("Authorization", "Bearer "+raw) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", rec.Code) + } + if called { + t.Error("handler must not run for a Viewer's mutating request") + } +} + +// TestIntegrationRequireSessionAllowsMutatingRequestFromEditor is the +// positive counterpart: an Editor-role session (the new floor) must +// still be able to reach mutating routes. +func TestIntegrationRequireSessionAllowsMutatingRequestFromEditor(t *testing.T) { + pool := integrationPool(t) + raw := insertTestSessionWithRole(t, pool, time.Hour, "editor") + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + handler := RequireSession(NewChecker(pool), next) + + req := httptest.NewRequest(http.MethodPost, "/targets", nil) + req.Header.Set("Authorization", "Bearer "+raw) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} + +// TestIntegrationRequireSessionAllowsReadFromViewer confirms the read +// path is untouched: GET still only needs a valid session, any role. +func TestIntegrationRequireSessionAllowsReadFromViewer(t *testing.T) { + pool := integrationPool(t) + raw := insertTestSessionWithRole(t, pool, time.Hour, "viewer") + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + handler := RequireSession(NewChecker(pool), next) + + req := httptest.NewRequest(http.MethodGet, "/targets", nil) + req.Header.Set("Authorization", "Bearer "+raw) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} diff --git a/alerting/internal/sessioncheck/sessioncheck_test.go b/alerting/internal/sessioncheck/sessioncheck_test.go new file mode 100644 index 0000000..ad9a67f --- /dev/null +++ b/alerting/internal/sessioncheck/sessioncheck_test.go @@ -0,0 +1,32 @@ +package sessioncheck + +import "testing" + +func TestRoleSatisfies(t *testing.T) { + cases := []struct { + role, min string + want bool + }{ + {"viewer", "editor", false}, + {"editor", "editor", true}, + {"admin", "editor", true}, + {"owner", "editor", true}, + {"", "editor", false}, // unknown/empty role never satisfies a real floor + } + for _, c := range cases { + if got := roleSatisfies(c.role, c.min); got != c.want { + t.Errorf("roleSatisfies(%q, %q) = %v, want %v", c.role, c.min, got, c.want) + } + } +} + +func TestIsReadOnly(t *testing.T) { + if !isReadOnly("GET") || !isReadOnly("HEAD") { + t.Error("GET/HEAD should be read-only") + } + for _, m := range []string{"POST", "PUT", "DELETE", "PATCH"} { + if isReadOnly(m) { + t.Errorf("%s should not be read-only", m) + } + } +} diff --git a/api/agents/handler.go b/api/agents/handler.go index ee46725..dd7bb16 100644 --- a/api/agents/handler.go +++ b/api/agents/handler.go @@ -4,8 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" + "path" + "strings" "github.com/sentry/sentry/api/authz" ) @@ -128,6 +131,30 @@ func (h *Handler) handleSetConfig(w http.ResponseWriter, r *http.Request) { return } + // extra_file_paths is a materially different capability than the + // rest of this override: every other field tunes an already-running + // source, but this one tells the agent (which runs as root, with no + // filesystem sandboxing today -- see the security audit) to read and + // ship an arbitrary local file. RoleEditor is the right bar for + // "adjust batch size," not for "grant read access to any file on the + // host" -- so a request that actually *changes* the set of extra + // paths (adds or edits one -- shrinking or clearing never needs + // this, since that only removes capability) requires RoleAdmin, + // checked here rather than by splitting /agents/{host}/config into + // two routes with two RegisterRoutes role floors, which would break + // the "PUT replaces the whole override" contract every field here + // otherwise shares. + // A nil authorizer means no RBAC is configured at all (Phase 0-3 + // default-open behavior) -- consistent with RequireRole's own + // no-op-when-nil posture, this extra gate only applies once an + // authorizer resolves a real Identity to check. + if identity, ok := authz.IdentityFromContext(r.Context()); ok && !identity.Role.Satisfies(authz.RoleAdmin) { + if changesExtraFilePaths(h.currentExtraFilePaths(r.Context(), h.tenantID(r), r.PathValue("host")), override.ExtraFilePaths) { + writeError(w, http.StatusForbidden, "extra_file_paths requires the admin role") + return + } + } + a, err := h.store.SetOverride(r.Context(), h.tenantID(r), r.PathValue("host"), override, h.updatedBy(r)) if err != nil { h.writeStoreErr(w, err, "setting agent config") @@ -202,9 +229,97 @@ func validateOverride(o ConfigOverride) error { if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 { return errors.New("heartbeat_interval_ms must be at least 5000 (5s)") } + if len(o.ExtraFilePaths) > 20 { + return errors.New("extra_file_paths: at most 20 paths") + } + for _, p := range o.ExtraFilePaths { + if err := validateExtraFilePath(p); err != nil { + return err + } + } return nil } +// extraFilePathDenylistPrefixes blocks whole directory trees that are +// never legitimate log-file locations but very commonly hold sensitive +// material an agent (which runs as root, unsandboxed, on every host +// this deployment has been checked against -- see the security audit) +// can otherwise read: OS credential/config storage, home directories, +// and kernel/process pseudo-filesystems. +var extraFilePathDenylistPrefixes = []string{"/etc/", "/root/", "/home/", "/proc/", "/sys/", "/boot/"} + +// extraFilePathDenylistSubstrings catches credential material that can +// live outside the directories above too (e.g. a service account's +// SSH/cloud-credential directory under an app's own working directory, +// not necessarily /home or /root). +var extraFilePathDenylistSubstrings = []string{"/.ssh/", "/.gnupg/", "/.aws/", "/.kube/"} + +// extraFilePathDenylistSuffixes catches specific high-value filenames by +// name, regardless of directory -- named here because the audit that +// motivated this check demonstrated /etc/shadow and an SSH private key +// specifically, and this covers both even outside the prefix-denylisted +// directories above (e.g. a private key accidentally copied to /opt). +var extraFilePathDenylistSuffixes = []string{"-key.pem", "id_rsa", "id_ecdsa", "id_ed25519", "id_dsa", "/shadow", "/gshadow"} + +func validateExtraFilePath(p string) error { + if p == "" || !strings.HasPrefix(p, "/") { + return errors.New("extra_file_paths: each path must be a non-empty absolute path") + } + if strings.Contains(p, "..") { + return errors.New(`extra_file_paths: path must not contain ".."`) + } + if cleaned := path.Clean(p); cleaned != p { + return fmt.Errorf("extra_file_paths: %q must be in canonical form (e.g. %q)", p, cleaned) + } + for _, prefix := range extraFilePathDenylistPrefixes { + if p == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(p, prefix) { + return fmt.Errorf("extra_file_paths: %q is not an allowed path (under denylisted %s)", p, prefix) + } + } + for _, substr := range extraFilePathDenylistSubstrings { + if strings.Contains(p, substr) { + return fmt.Errorf("extra_file_paths: %q is not an allowed path", p) + } + } + for _, suffix := range extraFilePathDenylistSuffixes { + if strings.HasSuffix(p, suffix) { + return fmt.Errorf("extra_file_paths: %q is not an allowed path", p) + } + } + return nil +} + +// currentExtraFilePaths reads back the agent's already-stored override +// (empty/nil if the agent or override doesn't exist yet) so +// handleSetConfig can tell an addition/change apart from a pure +// shrink-or-clear -- see changesExtraFilePaths. +func (h *Handler) currentExtraFilePaths(ctx context.Context, tenantID, host string) []string { + a, err := h.store.Get(ctx, tenantID, host) + if err != nil || a.DesiredOverride == nil { + return nil + } + return a.DesiredOverride.ExtraFilePaths +} + +// changesExtraFilePaths reports whether desired introduces any path not +// already present in current -- an addition or an edit, either of which +// grants the agent read access to something it couldn't read before. +// Removing paths (desired is a subset of current) is never a capability +// grant, so that alone never requires the stricter role handleSetConfig +// applies around this. +func changesExtraFilePaths(current, desired []string) bool { + existing := make(map[string]struct{}, len(current)) + for _, p := range current { + existing[p] = struct{}{} + } + for _, p := range desired { + if _, ok := existing[p]; !ok { + return true + } + } + return false +} + func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "agent not found") diff --git a/api/agents/handler_test.go b/api/agents/handler_test.go index 9d86272..913b48b 100644 --- a/api/agents/handler_test.go +++ b/api/agents/handler_test.go @@ -199,6 +199,116 @@ func TestHandleSetConfigRejectsTooSmallHeartbeatInterval(t *testing.T) { } } +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) diff --git a/api/agents/store.go b/api/agents/store.go index a74de94..4eb4ec6 100644 --- a/api/agents/store.go +++ b/api/agents/store.go @@ -35,11 +35,12 @@ func validCommand(c string) bool { // grpcserver.TenantIDHeaderKey, enterprise/internal/apiconfig.AIConfig). // Keep the three in sync by hand. type ConfigOverride struct { - BatchMaxSize *int64 `json:"batch_max_size,omitempty"` - BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"` - HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"` - HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"` - JournaldUnit *string `json:"journald_unit,omitempty"` + BatchMaxSize *int64 `json:"batch_max_size,omitempty"` + BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"` + HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"` + HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"` + JournaldUnit *string `json:"journald_unit,omitempty"` + ExtraFilePaths []string `json:"extra_file_paths,omitempty"` } type Agent struct { diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index 1abe79d..7cc87c4 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -7,7 +7,11 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" + "flag" "fmt" + "io" "log/slog" "net/http" "os" @@ -28,6 +32,7 @@ import ( "github.com/sentry/sentry/api/dashboards" "github.com/sentry/sentry/api/httpserver" "github.com/sentry/sentry/api/internal/config" + "github.com/sentry/sentry/api/localauth" "github.com/sentry/sentry/api/queryapi" "github.com/sentry/sentry/api/querylang/executor" "github.com/sentry/sentry/api/searchclient" @@ -48,6 +53,9 @@ func main() { logger.Error("loading config", "error", err) os.Exit(1) } + for _, w := range cfg.DevCredentialWarnings() { + logger.Warn(w) + } // -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a // flag anyone runs by hand. The api image is distroless (no shell, @@ -59,6 +67,13 @@ func main() { os.Exit(runHealthcheck(cfg.HTTPListenAddr)) } + // -seed-admin: a one-shot action, not part of the normal server + // startup path -- mirrors enterprise-api's -provision-tenant shape + // (declare, flag.Parse(), short-circuit before the rest of main's + // dependencies matter to it). See runSeedAdmin's doc comment. + seedAdmin := flag.Bool("seed-admin", false, "create the default local-auth admin user with a random password if none exists, print it once, and exit") + flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -101,12 +116,25 @@ func main() { os.Exit(1) } + if *seedAdmin { + os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool))) + } + // authorizer is nil (RequireRole* becomes a no-op) unless - // ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior - // for a single-tenant deployment with no enterprise/ deployed. + // ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches + // Phase 0-3 behavior for a single-tenant deployment with neither + // enterprise/ nor local login turned on. EnterpriseAuthURL wins if + // both were somehow set -- a deployment with real SSO configured has + // no use for a second, local auth mechanism (see LocalAuthConfig's + // doc comment). var authorizer authz.Authorizer - if cfg.EnterpriseAuthURL != "" { + var localAuthStore *localauth.Store + switch { + case cfg.EnterpriseAuthURL != "": authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL) + case cfg.LocalAuth.Enabled: + localAuthStore = localauth.NewStore(pgPool) + authorizer = localauth.NewAuthorizer(localAuthStore) } sqlRunner := executor.NewChRunner(conn) @@ -139,6 +167,18 @@ func main() { dashboardsHandler.RegisterRoutes(mux) agentsHandler.RegisterRoutes(mux) + // Only registered when local auth is actually enabled -- see + // localauth.Handler.RegisterRoutes' doc comment for why a disabled + // deployment gets a plain 404 on /auth/* rather than a dedicated + // "feature off" response. + if localAuthStore != nil { + localauthHandler := localauth.NewHandler(logger, localAuthStore, authorizer, cfg.LocalAuth.SessionTTL, localauth.CookieConfig{ + Domain: cfg.LocalAuth.CookieDomain, + Secure: cfg.LocalAuth.CookieSecure, + }) + localauthHandler.RegisterRoutes(mux) + } + // AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL // is set -- an unconfigured deployment gets a plain 404 on /ai/* // rather than every request failing against an unreachable @@ -165,9 +205,19 @@ func main() { logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel) } + // Once an authorizer is live, requests carry a session cookie/bearer + // token that must survive a cross-origin browser fetch -- + // WithCredentialedCORS is WithCORS's sibling for exactly that (see + // httpserver/cors.go). This also fixes a latent gap: previously, + // enterprise mode applied plain WithCORS here despite needing + // cookies too. + corsHandler := httpserver.WithCORS(mux, cfg.CORSAllowedOrigin) + if authorizer != nil { + corsHandler = httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin) + } srv := &http.Server{ Addr: cfg.HTTPListenAddr, - Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin), + Handler: corsHandler, } errCh := make(chan error, 1) @@ -191,6 +241,48 @@ func main() { } } +// runSeedAdmin is the operator action that bootstraps local login on a +// fresh deployment: idempotent (a no-op if any local user already +// exists, safe to run on every deploy per the runbook), so there's no +// separate "has this already run" flag to track. The generated +// password is printed to stdout exactly once and never stored in +// plaintext anywhere -- losing it means resetting it +// (POST /auth/users/{id}/reset-password), not recovering it. +func runSeedAdmin(ctx context.Context, logger *slog.Logger, stdout io.Writer, store *localauth.Store) int { + n, err := store.CountLocalUsers(ctx) + if err != nil { + logger.Error("counting local users", "error", err) + return 1 + } + if n > 0 { + fmt.Fprintln(stdout, "admin already provisioned, skipping") + return 0 + } + + buf := make([]byte, 20) + if _, err := rand.Read(buf); err != nil { + logger.Error("generating random password", "error", err) + return 1 + } + password := base64.RawURLEncoding.EncodeToString(buf) + + hash, err := localauth.HashPassword(password) + if err != nil { + logger.Error("hashing password", "error", err) + return 1 + } + if _, err := store.CreateUser(ctx, "admin", hash, authz.RoleOwner); err != nil { + logger.Error("creating admin user", "error", err) + return 1 + } + + fmt.Fprintln(stdout, "created default admin user:") + fmt.Fprintln(stdout, " username: admin") + fmt.Fprintf(stdout, " password: %s\n", password) + fmt.Fprintln(stdout, "this password will not be shown again -- save it now.") + return 0 +} + // runHealthcheck GETs its own /healthz and returns an exit code, for // Docker's HEALTHCHECK to exec directly (see the -healthcheck flag // above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") -- diff --git a/api/go.mod b/api/go.mod index 47a7da5..570c632 100644 --- a/api/go.mod +++ b/api/go.mod @@ -5,7 +5,9 @@ go 1.25.0 require ( github.com/ClickHouse/clickhouse-go/v2 v2.48.0 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 + golang.org/x/crypto v0.55.0 google.golang.org/grpc v1.83.0 ) @@ -19,7 +21,6 @@ require ( github.com/go-faster/errors v0.7.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.19.1 // indirect github.com/paulmach/orb v0.13.0 // indirect @@ -31,7 +32,7 @@ require ( golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/api/go.sum b/api/go.sum index 2f1b3cc..ee275b9 100644 --- a/api/go.sum +++ b/api/go.sum @@ -63,14 +63,16 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= diff --git a/api/internal/config/config.go b/api/internal/config/config.go index 1246544..e1176b8 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -18,6 +18,32 @@ type Config struct { CORSAllowedOrigin string EnterpriseAuthURL string AI AIConfig + LocalAuth LocalAuthConfig +} + +// LocalAuthConfig gates single-tenant mode's local username/password +// login (see api/localauth) -- off unless Enabled, same "off unless +// configured" convention as EnterpriseAuthURL/AI.OllamaBaseURL. Only +// meaningful when EnterpriseAuthURL is empty -- a deployment with real +// SSO configured has no use for a second, local auth mechanism, and +// main.go's authorizer selection treats EnterpriseAuthURL as taking +// priority if both were somehow set. +type LocalAuthConfig struct { + Enabled bool + // SessionTTL is deliberately long (30 days default) compared to + // enterprise/'s session TTL -- there's no SSO round-trip here to + // silently refresh a session against, so a short TTL would just mean + // re-entering a password often on a self-hosted single-operator tool. + SessionTTL time.Duration + // CookieDomain empty means a host-only cookie (fine for local dev, + // where web/api are both localhost:). Set to e.g. + // ".sentry.example.com" in production so the cookie is also sent to + // api.sentry.example.com/alerting.sentry.example.com. + CookieDomain string + // CookieSecure defaults true (never sent over plain HTTP) -- + // deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only + // useful to test the login flow locally over http://localhost. + CookieSecure bool } // AIConfig gates Phase 7's AI-assisted query features (Track A/B) -- @@ -50,6 +76,33 @@ type PostgresConfig struct { Password string } +// devOnlyCredential is docker-compose.yml's zero-config default for +// every Postgres/ClickHouse password in this repo -- genuinely fine for +// local dev (that's the whole point of a zero-config default), but a +// real deployment that skips docker-compose.override.yml would +// otherwise go live with a password anyone can read straight off +// GitHub. See DevCredentialWarnings. +const devOnlyCredential = "sentry-dev-only" + +// DevCredentialWarnings reports which configured credentials still +// equal docker-compose.yml's literal dev-only default -- cmd/api/main.go +// logs each one loudly at startup. Deliberately a warning, not a +// startup-refusing error: local dev's documented zero-config path is +// exactly "run docker-compose.yml with no override," which legitimately +// leaves every password at this literal value, so hard-failing here +// would break that path rather than only catching real deployments that +// forgot to override it. +func (c Config) DevCredentialWarnings() []string { + var warnings []string + if c.ClickHouse.Password == devOnlyCredential { + warnings = append(warnings, "CLICKHOUSE_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev") + } + if c.Postgres.Password == devOnlyCredential { + warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev") + } + return warnings +} + func Load() (Config, error) { cfg := Config{ HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"), @@ -98,6 +151,25 @@ func Load() (Config, error) { } cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second + localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false")) + if err != nil { + return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err) + } + sessionTTLHours, err := strconv.Atoi(getenv("LOCAL_SESSION_TTL_HOURS", "720")) // 30 days + if err != nil { + return Config{}, fmt.Errorf("LOCAL_SESSION_TTL_HOURS: %w", err) + } + cookieSecure, err := strconv.ParseBool(getenv("LOCAL_AUTH_COOKIE_SECURE", "true")) + if err != nil { + return Config{}, fmt.Errorf("LOCAL_AUTH_COOKIE_SECURE: %w", err) + } + cfg.LocalAuth = LocalAuthConfig{ + Enabled: localAuthEnabled, + SessionTTL: time.Duration(sessionTTLHours) * time.Hour, + CookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""), + CookieSecure: cookieSecure, + } + return cfg, nil } diff --git a/api/internal/config/config_test.go b/api/internal/config/config_test.go index 8eabb2d..52cb4ce 100644 --- a/api/internal/config/config_test.go +++ b/api/internal/config/config_test.go @@ -30,3 +30,23 @@ func TestLoadInvalidTimeoutErrors(t *testing.T) { t.Fatal("expected error for non-numeric QUERY_TIMEOUT_SECONDS, got nil") } } + +// TestDevCredentialWarnings is the regression test for the +// security-audit finding that docker-compose.yml's hardcoded +// "sentry-dev-only" password has no runtime fail-safe if an operator +// forgets to override it for a real deployment. +func TestDevCredentialWarnings(t *testing.T) { + if got := (Config{}).DevCredentialWarnings(); len(got) != 0 { + t.Errorf("empty passwords: warnings = %v, want none", got) + } + + real := Config{ClickHouse: ClickHouseConfig{Password: "a-real-password"}, Postgres: PostgresConfig{Password: "another-real-one"}} + if got := real.DevCredentialWarnings(); len(got) != 0 { + t.Errorf("real passwords: warnings = %v, want none", got) + } + + devOnly := Config{ClickHouse: ClickHouseConfig{Password: devOnlyCredential}, Postgres: PostgresConfig{Password: devOnlyCredential}} + if got := devOnly.DevCredentialWarnings(); len(got) != 2 { + t.Errorf("both dev-default passwords: warnings = %v, want 2 entries", got) + } +} diff --git a/api/internal/querylang/planner/planner.go b/api/internal/querylang/planner/planner.go index 291c702..80257fc 100644 --- a/api/internal/querylang/planner/planner.go +++ b/api/internal/querylang/planner/planner.go @@ -259,6 +259,24 @@ func defaultAggAlias(a ast.AggCall) string { // SQL parser -- same tradeoffs as the Phase 0/1 version this replaces. var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`) +// disallowedTableFunction blocks ClickHouse's built-in table functions +// that reach outside ClickHouse itself -- a keyword blocklist for +// mutating statements (above) doesn't touch these at all, since +// `SELECT * FROM url(...)` is a perfectly ordinary read-only SELECT as +// far as validateSelectOnly's other checks are concerned. Every +// function here lets a SELECT-only, RoleViewer-gated query make +// ClickHouse itself issue an outbound request or read a local file on +// the caller's behalf -- cloud-metadata SSRF via url(), a proxy into +// other internal ClickHouse/MySQL/Postgres instances via +// remote()/remoteSecure()/mysql()/postgresql(), and local/object-storage +// file reads via file()/hdfs()/s3()/azureBlobStorage()/deltaLake()/ +// iceberg()/hudi(). Same word-boundary-regex tradeoff as +// disallowedKeyword above: this is a blocklist, not a real SQL parser, +// so it can't be the only control -- see the ClickHouse-grant-level +// hardening this should be paired with (table-function usage revoked +// for the role api's raw-SQL path connects as). +var disallowedTableFunction = regexp.MustCompile(`(?i)\b(url|remote|remoteSecure|mysql|postgresql|s3|s3Cluster|hdfs|hdfsCluster|file|odbc|jdbc|executable|cluster|clusterAllReplicas|azureBlobStorage|deltaLake|iceberg|hudi|redis|mongodb)\s*\(`) + func validateSelectOnly(sql string) error { trimmed := strings.TrimSpace(sql) if trimmed == "" { @@ -281,6 +299,9 @@ func validateSelectOnly(sql string) error { if disallowedKeyword.MatchString(trimmed) { return fmt.Errorf("query contains a disallowed keyword") } + if disallowedTableFunction.MatchString(trimmed) { + return fmt.Errorf("query contains a disallowed table function") + } return nil } diff --git a/api/internal/querylang/planner/planner_test.go b/api/internal/querylang/planner/planner_test.go index 3dbcf2d..3588c68 100644 --- a/api/internal/querylang/planner/planner_test.go +++ b/api/internal/querylang/planner/planner_test.go @@ -40,6 +40,48 @@ func TestCompileRejectsNonSelectSQLKeyword(t *testing.T) { } } +// TestCompileRejectsSSRFTableFunctions guards against a real finding: a +// SELECT-only, keyword-blocklist check alone doesn't stop ClickHouse's +// built-in table functions, which let an otherwise-ordinary read-only +// SELECT make ClickHouse itself issue an outbound request (url, +// remote/remoteSecure, mysql, postgresql, s3, hdfs, ...) or read a local +// file (file) on the caller's behalf -- an SSRF/file-read primitive +// reachable by RoleViewer, the platform's lowest role. +func TestCompileRejectsSSRFTableFunctions(t *testing.T) { + queries := []string{ + `SELECT * FROM url('http://169.254.169.254/latest/meta-data/', 'LineAsString', 's String')`, + `select * from remote('internal-host:9000', system, tables)`, + `SELECT * FROM remoteSecure('attacker.example:9440', db, tbl, 'user', 'pass')`, + `select * from mysql('host:3306', 'db', 'table', 'user', 'pass')`, + `SELECT * FROM postgresql('host:5432', 'db', 'table', 'user', 'pass')`, + `select * from s3('https://bucket.s3.amazonaws.com/key', 'CSV')`, + `SELECT * FROM hdfs('hdfs://host:9000/path', 'CSV')`, + `select * from file('/etc/passwd', 'LineAsString')`, + `SELECT * FROM odbc('DSN=foo', 'db', 'table')`, + `select * from executable('id', 'TSV', 'x String')`, + `SELECT * FROM cluster('some_cluster', system, tables)`, + } + for _, q := range queries { + if _, err := Compile(q, SQL, fixedNow); err == nil { + t.Errorf("expected Compile(%q) to reject a table-function SSRF vector, got no error", q) + } + } +} + +// TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions makes +// sure the table-function blocklist only fires on actual function-call +// syntax (name immediately followed by "(") and not merely a column or +// identifier that happens to share a name with a blocked function. +func TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions(t *testing.T) { + plan, err := Compile(`SELECT cluster_id, file_name FROM logs WHERE cluster_id = 1`, SQL, fixedNow) + if err != nil { + t.Fatalf("Compile() error = %v", err) + } + if plan.RawSQL == "" { + t.Fatal("expected RawSQL to be set") + } +} + func TestCompileExplicitLanguageOverridesAutoDetect(t *testing.T) { // "select" as a bare free-text search term -- would be misdetected // as SQL by the heuristic alone, hence the override. diff --git a/api/localauth/authorizer.go b/api/localauth/authorizer.go new file mode 100644 index 0000000..e601a02 --- /dev/null +++ b/api/localauth/authorizer.go @@ -0,0 +1,70 @@ +package localauth + +import ( + "context" + "errors" + "net/http" + + "github.com/sentry/sentry/api/authz" +) + +// sessionStore is the narrow interface Authorizer depends on -- *Store +// is the production implementation; tests use a fake. +type sessionStore interface { + GetSession(ctx context.Context, tokenHash string) (*Session, error) +} + +// sessionCookieName is also read directly by handler.go (Set-Cookie on +// login/logout) and is the one piece of this package's shape web/ +// needs to know about implicitly (via credentials: 'include', not by +// name -- the browser handles the cookie, JS never reads it since it's +// HttpOnly). +const sessionCookieName = "sentry_local_session" + +// Authorizer implements api/authz.Authorizer against local_sessions -- +// wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer +// variable is what turns every existing RequireRole-wrapped route in +// dashboards/agents/queryapi/aiapi from a no-op into real enforcement, +// with no changes needed to any of those handler files (see this +// package's doc comment). +type Authorizer struct { + store sessionStore +} + +func NewAuthorizer(store sessionStore) *Authorizer { + return &Authorizer{store: store} +} + +var errNoCredential = errors.New("localauth: no session credential presented") + +// Authorize checks Authorization: Bearer first (sentryctl and other +// non-browser callers), then the session cookie (the web UI) -- same +// precedence authz.HTTPAuthorizer's caller-side forwarding implies, +// and the same reason POST /auth/login's response body returns the raw +// token alongside setting the cookie (see handler.go): one opaque +// value works both ways. +func (a *Authorizer) Authorize(r *http.Request) (authz.Identity, error) { + raw, err := credentialFromRequest(r) + if err != nil { + return authz.Identity{}, err + } + + sess, err := a.store.GetSession(r.Context(), hashToken(raw)) + if err != nil { + return authz.Identity{}, err + } + return authz.Identity{TenantID: sess.TenantID, UserID: sess.UserID, Role: sess.Role}, nil +} + +func credentialFromRequest(r *http.Request) (string, error) { + if auth := r.Header.Get("Authorization"); auth != "" { + const prefix = "Bearer " + if len(auth) > len(prefix) && auth[:len(prefix)] == prefix { + return auth[len(prefix):], nil + } + } + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + return cookie.Value, nil + } + return "", errNoCredential +} diff --git a/api/localauth/fake_test.go b/api/localauth/fake_test.go new file mode 100644 index 0000000..e0de852 --- /dev/null +++ b/api/localauth/fake_test.go @@ -0,0 +1,127 @@ +package localauth + +import ( + "context" + "strconv" + "time" + + "github.com/sentry/sentry/api/authz" +) + +// fakeStore implements both store (handler.go) and sessionStore +// (authorizer.go) -- a real *Store satisfies both too, this is just the +// in-memory test double, same "fake enforces the same invariants the +// real pgx-backed Store does" posture dashboards/handler_test.go's +// fakeStore documents. +type fakeStore struct { + users map[string]*User // by id + hashes map[string]string + byUsername map[string]string // username -> id + sessions map[string]Session + nextID int + createErr error +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + users: map[string]*User{}, + hashes: map[string]string{}, + byUsername: map[string]string{}, + sessions: map[string]Session{}, + } +} + +func (f *fakeStore) CreateUser(_ context.Context, username, passwordHash string, role authz.Role) (*User, error) { + if f.createErr != nil { + return nil, f.createErr + } + if _, ok := f.byUsername[username]; ok { + return nil, ErrUsernameTaken + } + f.nextID++ + id := "user-" + strconv.Itoa(f.nextID) + u := &User{ID: id, Username: username, Role: role, CreatedAt: time.Now()} + f.users[id] = u + f.hashes[id] = passwordHash + f.byUsername[username] = id + return u, nil +} + +func (f *fakeStore) ListUsers(_ context.Context) ([]User, error) { + var out []User + for _, u := range f.users { + out = append(out, *u) + } + return out, nil +} + +func (f *fakeStore) GetUserForLogin(_ context.Context, username string) (*User, string, error) { + id, ok := f.byUsername[username] + if !ok { + return nil, "", ErrNotFound + } + return f.users[id], f.hashes[id], nil +} + +func (f *fakeStore) GetUserByID(_ context.Context, id string) (*User, error) { + u, ok := f.users[id] + if !ok { + return nil, ErrNotFound + } + return u, nil +} + +func (f *fakeStore) DeleteUser(_ context.Context, id string) error { + u, ok := f.users[id] + if !ok { + return ErrNotFound + } + delete(f.users, id) + delete(f.hashes, id) + delete(f.byUsername, u.Username) + for hash, sess := range f.sessions { + if sess.UserID == id { + delete(f.sessions, hash) + } + } + return nil +} + +func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) error { + if _, ok := f.users[userID]; !ok { + return ErrNotFound + } + f.hashes[userID] = hash + for h, sess := range f.sessions { + if sess.UserID == userID { + delete(f.sessions, h) + } + } + return nil +} + +func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) { + return len(f.users), nil +} + +func (f *fakeStore) CreateSession(_ context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) { + raw, hash, err := newOpaqueToken() + if err != nil { + return "", err + } + f.sessions[hash] = Session{UserID: userID, TenantID: tenantID, Role: role, ExpiresAt: time.Now().Add(ttl)} + return raw, nil +} + +func (f *fakeStore) GetSession(_ context.Context, tokenHash string) (*Session, error) { + sess, ok := f.sessions[tokenHash] + if !ok || sess.ExpiresAt.Before(time.Now()) { + return nil, ErrNotFound + } + return &sess, nil +} + +func (f *fakeStore) DeleteSessionByHash(_ context.Context, tokenHash string) error { + delete(f.sessions, tokenHash) + return nil +} diff --git a/api/localauth/handler.go b/api/localauth/handler.go new file mode 100644 index 0000000..2d66a65 --- /dev/null +++ b/api/localauth/handler.go @@ -0,0 +1,407 @@ +package localauth + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "time" + + "github.com/sentry/sentry/api/authz" +) + +const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents + +// store is the narrow interface Handler depends on -- *Store (store.go) +// is the production implementation; tests use a fake, same pattern as +// dashboards.store/agents.store. +type store interface { + CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error) + ListUsers(ctx context.Context) ([]User, error) + GetUserForLogin(ctx context.Context, username string) (*User, string, error) + GetUserByID(ctx context.Context, id string) (*User, error) + DeleteUser(ctx context.Context, id string) error + SetPasswordHash(ctx context.Context, userID, hash string) error + CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) + DeleteSessionByHash(ctx context.Context, tokenHash string) error +} + +// CookieConfig is the deployment-specific half of how the session +// cookie is set -- everything else about it (name, HttpOnly, SameSite) +// is fixed by this package, not configurable per deployment. +type CookieConfig struct { + // Domain is typically empty for local dev (host-only cookie, works + // fine when web/api are both localhost:) and something like + // ".sentry.example.com" in production, so the same cookie is sent to + // api.sentry.example.com and alerting.sentry.example.com too -- see + // /docs (deployment runbook) for the subdomain scheme this assumes. + Domain string + // Secure defaults to true (the cookie is never sent over plain + // HTTP) -- deliberately opt-out, not opt-in, since the real + // deployment this feature exists for is always behind HTTPS. Only + // worth setting false to test the login flow locally over plain + // http://localhost. + Secure bool +} + +// loginRateLimitMax/Window bound how many login attempts one client IP +// may make -- see loginLimiter's doc comment for why this is per-IP, +// in-memory, and counts both successful and failed attempts. 10 per 5 +// minutes is generous enough that a real user mistyping a password a +// few times never notices, while still bounding an online brute-force +// attempt to a few attempts per minute. +const ( + loginRateLimitMax = 10 + loginRateLimitWindow = 5 * time.Minute +) + +type Handler struct { + logger *slog.Logger + store store + authorizer authz.Authorizer + sessionTTL time.Duration + cookies CookieConfig + loginLimits *loginLimiter +} + +func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, sessionTTL time.Duration, cookies CookieConfig) *Handler { + return &Handler{ + logger: logger, + store: store, + authorizer: authorizer, + sessionTTL: sessionTTL, + cookies: cookies, + loginLimits: newLoginLimiter(loginRateLimitMax, loginRateLimitWindow), + } +} + +// RegisterRoutes is only ever called when local auth is enabled (see +// cmd/api/main.go) -- a deployment that doesn't enable it simply never +// registers these routes at all, so GET /auth/session (etc.) 404s +// rather than needing its own "is this feature even on" response +// shape. Login/logout/session are deliberately NOT RequireRole-wrapped +// with anything above RoleViewer's floor: login is how you become +// authenticated in the first place, logout/session must work for any +// already-authenticated user regardless of role. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /auth/login", h.handleLogin) + mux.HandleFunc("POST /auth/logout", h.handleLogout) + mux.HandleFunc("GET /auth/session", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGetSession)) + + mux.HandleFunc("GET /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleListUsers)) + mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser)) + mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser)) + mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword)) +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type sessionResponse struct { + // Token duplicates what the Set-Cookie header already carries, + // specifically for non-browser callers with no cookie jar -- + // sentryctl captures this into SENTRYCTL_TOKEN and sends it back as + // Authorization: Bearer (see authorizer.go's credentialFromRequest, + // which accepts either). The web UI ignores this field entirely and + // relies on the cookie. + Token string `json:"token"` + UserID string `json:"user_id"` + TenantID string `json:"tenant_id"` + Username string `json:"username"` + Role string `json:"role"` +} + +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + if !h.loginLimits.allow(clientIP(r)) { + writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later") + return + } + + var req loginRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "username and password are required") + return + } + + user, hash, err := h.store.GetUserForLogin(r.Context(), req.Username) + if err != nil { + if errors.Is(err, ErrNotFound) { + // Run a dummy bcrypt comparison even though there's no real + // hash to check -- otherwise this branch returns immediately + // while a known-username branch always pays bcrypt's cost + // below, and that timing gap lets a patient caller enumerate + // valid usernames by response latency alone even though the + // error message text is identical either way. + ComparePassword(dummyPasswordHash, req.Password) + writeError(w, http.StatusUnauthorized, "invalid username or password") + return + } + h.logger.Error("looking up user for login", "error", err) + writeError(w, http.StatusInternalServerError, "login failed") + return + } + if !ComparePassword(hash, req.Password) { + writeError(w, http.StatusUnauthorized, "invalid username or password") + return + } + + raw, err := h.store.CreateSession(r.Context(), user.ID, defaultTenantID, user.Role, h.sessionTTL) + if err != nil { + h.logger.Error("creating session", "error", err) + writeError(w, http.StatusInternalServerError, "login failed") + return + } + + h.setCookie(w, raw, h.sessionTTL) + writeJSON(w, http.StatusOK, sessionResponse{ + Token: raw, UserID: user.ID, TenantID: defaultTenantID, + Username: user.Username, Role: string(user.Role), + }) +} + +// handleLogout always responds 204, whether or not a valid session was +// presented -- "log me out" is idempotent from the caller's point of +// view either way. +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + if raw, err := credentialFromRequest(r); err == nil { + if err := h.store.DeleteSessionByHash(r.Context(), hashToken(raw)); err != nil { + h.logger.Error("deleting session", "error", err) + } + } + h.clearCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +// handleGetSession is what web's route guard (+layout.ts) polls on +// every navigation -- RequireRole(RoleViewer) above already turns "no +// valid session" into a 401 before this ever runs, so by the time +// we're here the identity is real. +func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { + identity, _ := authz.IdentityFromContext(r.Context()) + user, err := h.store.GetUserByID(r.Context(), identity.UserID) + if err != nil { + h.writeStoreErr(w, err, "fetching session user") + return + } + writeJSON(w, http.StatusOK, sessionResponse{ + UserID: user.ID, TenantID: identity.TenantID, Username: user.Username, Role: string(user.Role), + }) +} + +type userResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.store.ListUsers(r.Context()) + if err != nil { + h.logger.Error("listing users", "error", err) + writeError(w, http.StatusInternalServerError, "listing users failed") + return + } + out := make([]userResponse, len(users)) + for i, u := range users { + out[i] = userResponse{ID: u.ID, Username: u.Username, Role: string(u.Role), CreatedAt: u.CreatedAt} + } + writeJSON(w, http.StatusOK, out) +} + +type createUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` +} + +func validRole(r authz.Role) bool { + switch r { + case authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner: + return true + default: + return false + } +} + +func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var req createUserRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Username == "" { + writeError(w, http.StatusBadRequest, "username must not be empty") + return + } + if len(req.Password) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + role := authz.Role(req.Role) + if role == "" { + role = authz.RoleEditor + } + if !validRole(role) { + writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`) + return + } + + hash, err := HashPassword(req.Password) + if err != nil { + h.logger.Error("hashing password", "error", err) + writeError(w, http.StatusInternalServerError, "creating user failed") + return + } + user, err := h.store.CreateUser(r.Context(), req.Username, hash, role) + if err != nil { + if errors.Is(err, ErrUsernameTaken) { + writeError(w, http.StatusConflict, "username already taken") + return + } + h.logger.Error("creating user", "error", err) + writeError(w, http.StatusInternalServerError, "creating user failed") + return + } + writeJSON(w, http.StatusCreated, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt}) +} + +// handleDeleteUser deliberately does not stop an admin from deleting +// their own account -- this package has no separate "you can't remove +// the last admin" guard; a single-operator prototype deployment is +// expected to know what it's doing here, same trust level the rest of +// this codebase's admin-only endpoints assume. +func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + if err := h.store.DeleteUser(r.Context(), r.PathValue("id")); err != nil { + h.writeStoreErr(w, err, "deleting user") + return + } + w.WriteHeader(http.StatusNoContent) +} + +type resetPasswordRequest struct { + // Password is optional -- omitted, a random one is generated and + // returned in the response body exactly once, same "shown once, + // never stored, never recoverable" posture as -seed-admin's initial + // password (see cmd/api/main.go's runSeedAdmin). + Password string `json:"password,omitempty"` +} + +type resetPasswordResponse struct { + // Password is only set when the request didn't supply one -- + // omitempty so an admin-supplied reset doesn't echo it back. + Password string `json:"password,omitempty"` +} + +func (h *Handler) handleResetPassword(w http.ResponseWriter, r *http.Request) { + var req resetPasswordRequest + // An empty body is valid here (generate a random password) -- + // decodeJSON's json.Decode on an empty io.Reader would error, so + // this endpoint reads the body directly instead of reusing + // decodeJSON, tolerating "no body at all" as "use defaults." + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + if r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + } + + plaintext := req.Password + generated := false + if plaintext == "" { + raw, _, err := newOpaqueToken() + if err != nil { + h.logger.Error("generating random password", "error", err) + writeError(w, http.StatusInternalServerError, "resetting password failed") + return + } + plaintext = raw + generated = true + } else if len(plaintext) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + + hash, err := HashPassword(plaintext) + if err != nil { + h.logger.Error("hashing password", "error", err) + writeError(w, http.StatusInternalServerError, "resetting password failed") + return + } + if err := h.store.SetPasswordHash(r.Context(), r.PathValue("id"), hash); err != nil { + h.writeStoreErr(w, err, "resetting password") + return + } + + resp := resetPasswordResponse{} + if generated { + resp.Password = plaintext + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) setCookie(w http.ResponseWriter, raw string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: raw, + Domain: h.cookies.Domain, + Path: "/", + MaxAge: int(ttl.Seconds()), + HttpOnly: true, + Secure: h.cookies.Secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (h *Handler) clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Domain: h.cookies.Domain, + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: h.cookies.Secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return + } + h.logger.Error(action, "error", err) + writeError(w, http.StatusInternalServerError, action+" failed") +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) +} diff --git a/api/localauth/handler_test.go b/api/localauth/handler_test.go new file mode 100644 index 0000000..0df5893 --- /dev/null +++ b/api/localauth/handler_test.go @@ -0,0 +1,261 @@ +package localauth + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sentry/sentry/api/authz" +) + +func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) { + t.Helper() + authorizer := NewAuthorizer(fs) + h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, authorizer, time.Hour, CookieConfig{}) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + return h, mux +} + +func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, r) + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func mustCreateUser(t *testing.T, fs *fakeStore, username, password string, role authz.Role) *User { + t.Helper() + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("hashing password: %v", err) + } + u, err := fs.CreateUser(t.Context(), username, hash, role) + if err != nil { + t.Fatalf("creating user: %v", err) + } + return u +} + +func sessionCookieFrom(rec *httptest.ResponseRecorder) *http.Cookie { + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookieName { + return c + } + } + return nil +} + +func TestLoginSuccess(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + cookie := sessionCookieFrom(rec) + if cookie == nil || cookie.Value == "" { + t.Fatalf("expected a session cookie to be set, got none") + } + if !cookie.HttpOnly { + t.Errorf("session cookie must be HttpOnly") + } + + var resp sessionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp.Token == "" { + t.Errorf("expected the response body to also carry the raw token for non-browser callers") + } + if resp.Role != "editor" { + t.Errorf("role = %q, want editor", resp.Role) + } +} + +func TestLoginWrongPassword(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestLoginUnknownUserSameErrorAsWrongPassword(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + unknown := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"whatever"}`, nil) + wrongPass := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil) + + if unknown.Code != http.StatusUnauthorized || wrongPass.Code != http.StatusUnauthorized { + t.Fatalf("both must be 401, got unknown=%d wrongPass=%d", unknown.Code, wrongPass.Code) + } + if unknown.Body.String() != wrongPass.Body.String() { + t.Errorf("responses must be identical (no username enumeration): unknown=%q wrongPass=%q", unknown.Body.String(), wrongPass.Body.String()) + } +} + +func TestSessionRequiresAuth(t *testing.T) { + fs := newFakeStore() + _, mux := newTestHandler(t, fs) + + rec := doRequest(t, mux, http.MethodGet, "/auth/session", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 with no session cookie", rec.Code) + } +} + +func TestLoginThenSessionRoundTrip(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil) + cookie := sessionCookieFrom(login) + + sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie) + if sess.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", sess.Code, sess.Body.String()) + } + var resp sessionResponse + if err := json.Unmarshal(sess.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp.Username != "alice" { + t.Errorf("username = %q, want alice", resp.Username) + } +} + +func TestLogoutInvalidatesSession(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil) + cookie := sessionCookieFrom(login) + + logout := doRequest(t, mux, http.MethodPost, "/auth/logout", "", cookie) + if logout.Code != http.StatusNoContent { + t.Fatalf("logout status = %d, want 204", logout.Code) + } + + sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie) + if sess.Code != http.StatusUnauthorized { + t.Fatalf("status after logout = %d, want 401 (session must be revoked)", sess.Code) + } +} + +func TestNonOwnerCannotManageUsers(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil) + cookie := sessionCookieFrom(login) + + rec := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for a non-owner listing users", rec.Code) + } +} + +func TestOwnerCanCreateAndDeleteUsers(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil) + cookie := sessionCookieFrom(login) + + create := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"bobspassword","role":"viewer"}`, cookie) + if create.Code != http.StatusCreated { + t.Fatalf("create status = %d, want 201; body=%s", create.Code, create.Body.String()) + } + var created userResponse + if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { + t.Fatalf("decoding response: %v", err) + } + if created.Role != "viewer" { + t.Errorf("role = %q, want viewer", created.Role) + } + if created.CreatedAt.IsZero() { + t.Errorf("created_at was not populated in the create response") + } + + list := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie) + var users []userResponse + if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(users) != 2 { + t.Fatalf("len(users) = %d, want 2 (admin + bob)", len(users)) + } + + del := doRequest(t, mux, http.MethodDelete, "/auth/users/"+created.ID, "", cookie) + if del.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want 204", del.Code) + } +} + +func TestCreateUserRejectsShortPassword(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil) + cookie := sessionCookieFrom(login) + + rec := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"short"}`, cookie) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for a too-short password", rec.Code) + } +} + +func TestResetPasswordRevokesExistingSessions(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner) + bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer) + _, mux := newTestHandler(t, fs) + + adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil) + adminCookie := sessionCookieFrom(adminLogin) + bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil) + bobCookie := sessionCookieFrom(bobLogin) + + reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", "", adminCookie) + if reset.Code != http.StatusOK { + t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String()) + } + var resp resetPasswordResponse + if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp.Password == "" { + t.Fatalf("expected a generated password in the response when none was supplied") + } + + stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie) + if stale.Code != http.StatusUnauthorized { + t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code) + } +} diff --git a/api/localauth/password.go b/api/localauth/password.go new file mode 100644 index 0000000..da69b0d --- /dev/null +++ b/api/localauth/password.go @@ -0,0 +1,29 @@ +package localauth + +import "golang.org/x/crypto/bcrypt" + +// dummyPasswordHash is a precomputed bcrypt hash of an arbitrary, +// never-used-as-a-real-password string -- handleLogin runs +// ComparePassword against this on the "no such user" path purely to pay +// the same bcrypt cost the "wrong password" path already pays, closing +// a response-time side channel that would otherwise let a caller +// distinguish the two despite their identical error message. There is +// no real password behind this hash; it exists only to burn comparable +// CPU time. +const dummyPasswordHash = "$2a$10$fH9R3O6ViQ6c7bq0N7yyBO1JP2TOw/bZEopMyZKBYrBjgYBZO9rCa" + +// HashPassword and ComparePassword are the only two places this package +// touches a raw password -- everywhere else, a user is identified by an +// already-issued session token (see token.go), never by re-checking a +// password on every request. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func ComparePassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/api/localauth/ratelimit.go b/api/localauth/ratelimit.go new file mode 100644 index 0000000..c671e6c --- /dev/null +++ b/api/localauth/ratelimit.go @@ -0,0 +1,91 @@ +package localauth + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// loginLimiter is a simple in-memory sliding-window rate limiter for +// POST /auth/login, keyed by client IP -- closes a real gap the +// security audit found: nothing in the application, nginx, or the host +// (no fail2ban either) throttled repeated login attempts, making +// sustained online brute-forcing of a weaker, human-chosen password +// possible. (The auto-generated admin password is high-entropy, but +// every user created afterward only needs 8 characters with no +// complexity check -- see handleCreateUser.) +// +// Per-IP rather than per-username: a per-username-only limiter is +// itself a denial-of-service vector (deliberately fail a real +// username's login repeatedly, from anywhere, to lock them out), and +// wouldn't bound an attacker guessing across many usernames from one +// source. Both successful and failed attempts count against the +// window, not just failures -- simpler, and it means a low-and-slow +// guesser can't reset their budget by occasionally succeeding against +// an unrelated account. +// +// Deliberately in-memory, not Postgres-backed: login rate limiting is +// inherently best-effort per-process state (a restart clearing it is +// fine, unlike a session or password), and adding a database +// round-trip to every login attempt is the wrong tradeoff for a check +// whose only job is bounding attempt *rate*. Memory for IPs that stop +// attempting entirely is only reclaimed the next time that exact key is +// looked up -- a deliberate, bounded-in-practice simplicity tradeoff +// (real attacker/user IP cardinality against one deployment is small +// relative to a process's lifetime between deploys), not an oversight. +type loginLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + max int + window time.Duration +} + +func newLoginLimiter(max int, window time.Duration) *loginLimiter { + return &loginLimiter{attempts: map[string][]time.Time{}, max: max, window: window} +} + +// allow reports whether key may attempt another login right now, and +// records this attempt if so (a denied call does not itself count as a +// new attempt -- it just reports the existing window is full). +func (l *loginLimiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-l.window) + var kept []time.Time + for _, t := range l.attempts[key] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= l.max { + l.attempts[key] = kept + return false + } + l.attempts[key] = append(kept, now) + return true +} + +// clientIP extracts the caller's address for rate-limiting purposes. +// Trusts the first hop of X-Forwarded-For when present -- correct for +// this deployment's actual topology (always behind nginx, which sets +// it), but note this is spoofable by any caller that reaches the +// application directly rather than through the trusted proxy; a +// deployment that exposes api's port directly to untrusted clients +// should not rely on this header. Falls back to r.RemoteAddr, which is +// always accurate for whoever the TCP connection is actually with. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if first, _, ok := strings.Cut(xff, ","); ok { + return strings.TrimSpace(first) + } + return strings.TrimSpace(xff) + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} diff --git a/api/localauth/ratelimit_test.go b/api/localauth/ratelimit_test.go new file mode 100644 index 0000000..7ae4bdf --- /dev/null +++ b/api/localauth/ratelimit_test.go @@ -0,0 +1,84 @@ +package localauth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/sentry/sentry/api/authz" +) + +func TestLoginLimiterAllowsUpToMax(t *testing.T) { + l := newLoginLimiter(3, time.Minute) + for i := 0; i < 3; i++ { + if !l.allow("1.2.3.4") { + t.Fatalf("attempt %d: want allowed", i+1) + } + } + if l.allow("1.2.3.4") { + t.Fatal("4th attempt within the window: want denied") + } +} + +func TestLoginLimiterIsPerKey(t *testing.T) { + l := newLoginLimiter(1, time.Minute) + if !l.allow("1.2.3.4") { + t.Fatal("first attempt from 1.2.3.4: want allowed") + } + if !l.allow("5.6.7.8") { + t.Fatal("a different IP must have its own budget") + } + if l.allow("1.2.3.4") { + t.Fatal("second attempt from 1.2.3.4: want denied") + } +} + +func TestLoginLimiterResetsAfterWindow(t *testing.T) { + l := newLoginLimiter(1, 10*time.Millisecond) + if !l.allow("1.2.3.4") { + t.Fatal("first attempt: want allowed") + } + if l.allow("1.2.3.4") { + t.Fatal("second attempt within the window: want denied") + } + time.Sleep(20 * time.Millisecond) + if !l.allow("1.2.3.4") { + t.Fatal("attempt after the window elapsed: want allowed") + } +} + +func TestClientIPPrefersForwardedFor(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + r.RemoteAddr = "10.0.0.1:5555" + r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") + if got := clientIP(r); got != "203.0.113.9" { + t.Errorf("clientIP() = %q, want %q", got, "203.0.113.9") + } +} + +func TestClientIPFallsBackToRemoteAddr(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + r.RemoteAddr = "198.51.100.7:5555" + if got := clientIP(r); got != "198.51.100.7" { + t.Errorf("clientIP() = %q, want %q", got, "198.51.100.7") + } +} + +// TestHandleLoginRateLimited is the regression test for the +// security-audit finding that POST /auth/login had no rate limiting at +// all -- repeated attempts from the same client must eventually get a +// 429, not another 401. +func TestHandleLoginRateLimited(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor) + _, mux := newTestHandler(t, fs) + + var last *httptest.ResponseRecorder + for i := 0; i < loginRateLimitMax+1; i++ { + last = doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong-password"}`, nil) + } + if last.Code != http.StatusTooManyRequests { + t.Fatalf("status after exceeding the limit = %d, want 429", last.Code) + } +} diff --git a/api/localauth/store.go b/api/localauth/store.go new file mode 100644 index 0000000..2189036 --- /dev/null +++ b/api/localauth/store.go @@ -0,0 +1,302 @@ +// Package localauth is single-tenant mode's local username/password +// login: a real login page and session-based auth covering both /api +// and /alerting, plus a simple admin-managed user list, for deployments +// reachable over the internet that can no longer rely on Phase 0-3's +// "no auth yet" default (see /docs/architecture.md and CLAUDE.md's +// Phase 4 section for the enterprise/ SSO alternative this is not -- +// this package has no tenant/RBAC-service concept, just "is this a +// valid logged-in user"). +// +// Deliberately extends the existing users/tenants/tenant_memberships +// schema (0017/0018/0020_*.sql, built for Phase 4 SSO) rather than a +// parallel local_users table: tenant_memberships.role is already +// constrained to exactly authz.Role's four human values, so a local +// login gets real 4-tier roles for free, and a deployment that later +// turns on enterprise SSO has one identity graph to reconcile, not two. +// Every local user is a member of the "default" tenant only -- this +// package has no notion of provisioning additional tenants. +// +// Authorizer (authorizer.go) is what api/cmd/api/main.go wires into +// api/authz's Authorizer slot for a single-tenant deployment that wants +// real auth -- once that's non-nil, every existing RequireRole-wrapped +// route in dashboards/agents/queryapi/aiapi starts enforcing roles for +// free, no other handler file needs to change. alerting has no such +// per-route plumbing at all, so it gets its own, much smaller, +// deliberately-duplicated package (alerting/internal/sessioncheck) that +// only ever validates an already-issued session -- see that package's +// doc comment for why this isn't imported from here instead. +package localauth + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/sentry/sentry/api/authz" +) + +var ( + ErrNotFound = errors.New("not found") + ErrUsernameTaken = errors.New("username already taken") +) + +// defaultTenantID is the only tenant a local user can ever belong to -- +// see the package doc comment. Matches every other single-tenant +// deployment's "default" tenant_id convention (dashboards, agents, +// alert_rules). +const defaultTenantID = "default" + +type User struct { + ID string + Username string + Role authz.Role + CreatedAt time.Time +} + +type Session struct { + UserID string + TenantID string + Role authz.Role + ExpiresAt time.Time +} + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// CreateUser inserts a new local user and, in the same transaction, the +// tenant_memberships row that gives them role in the default tenant -- +// a local user with no membership row would authenticate successfully +// (CreateSession has nothing that requires one) but satisfy no +// RequireRole check at all, so the two rows are never created +// separately. +func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + id := uuid.NewString() + var createdAt time.Time + err = tx.QueryRow(ctx, ` + INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at) + VALUES ($1, $2, $3, $2, now(), now()) + RETURNING created_at`, + id, username, passwordHash).Scan(&createdAt) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrUsernameTaken + } + return nil, err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO tenant_memberships (id, tenant_id, user_id, role) + VALUES ($1, $2, $3, $4)`, + uuid.NewString(), defaultTenantID, id, string(role)); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &User{ID: id, Username: username, Role: role, CreatedAt: createdAt}, nil +} + +const listColumns = ` + u.id, u.username, tm.role, u.created_at` + +// ListUsers only ever returns local users (username IS NOT NULL) -- +// an SSO-provisioned user with no password_hash/username set never +// appears here, since there's nothing for this package's user manager +// to do with one. +func (s *Store) ListUsers(ctx context.Context) ([]User, error) { + rows, err := s.pool.Query(ctx, ` + SELECT `+listColumns+` + FROM users u + JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1 + WHERE u.username IS NOT NULL + ORDER BY u.username`, defaultTenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + var role string + if err := rows.Scan(&u.ID, &u.Username, &role, &u.CreatedAt); err != nil { + return nil, err + } + u.Role = authz.Role(role) + out = append(out, u) + } + return out, rows.Err() +} + +// GetUserForLogin returns the user and their password hash together -- +// the only place this package ever reads a password_hash back out, and +// only to feed ComparePassword. Everywhere else uses User, which never +// carries the hash. +func (s *Store) GetUserForLogin(ctx context.Context, username string) (*User, string, error) { + var u User + var role, hash string + err := s.pool.QueryRow(ctx, ` + SELECT u.id, u.username, u.password_hash, tm.role, u.created_at + FROM users u + JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1 + WHERE u.username = $2`, defaultTenantID, username). + Scan(&u.ID, &u.Username, &hash, &role, &u.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, "", ErrNotFound + } + return nil, "", err + } + u.Role = authz.Role(role) + return &u, hash, nil +} + +// GetUserByID backs GET /auth/session -- looking up the identity +// RequireRole already resolved and attached to the request context, to +// return its username (Session/Identity carry no username, only IDs). +func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { + var u User + var role string + err := s.pool.QueryRow(ctx, ` + SELECT u.id, u.username, tm.role, u.created_at + FROM users u + JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1 + WHERE u.id = $2 AND u.username IS NOT NULL`, defaultTenantID, id). + Scan(&u.ID, &u.Username, &role, &u.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + u.Role = authz.Role(role) + return &u, nil +} + +// DeleteUser cascades to the user's tenant_memberships and +// local_sessions rows (both ON DELETE CASCADE) -- a deleted user's +// existing sessions stop validating immediately, not just their next +// login. +func (s *Store) DeleteUser(ctx context.Context, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1 AND username IS NOT NULL`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// SetPasswordHash also revokes every existing session for userID, in +// the same transaction -- Session.Role/TenantID are a snapshot taken at +// login (see 0041_create_local_sessions.sql's doc comment), so without +// this an account whose password was just reset for security reasons +// would keep any already-issued session working regardless. +func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2 AND username IS NOT NULL`, hash, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil { + return err + } + return tx.Commit(ctx) +} + +// CountLocalUsers backs -seed-admin's idempotency check (see +// cmd/api/main.go's runSeedAdmin): a deployment that already has at +// least one local user never gets a second auto-created admin account. +func (s *Store) CountLocalUsers(ctx context.Context) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE username IS NOT NULL`).Scan(&n) + return n, err +} + +// CreateSession mints a fresh opaque token for an already-authenticated +// user (login has already verified their password by the time this is +// called) and stores its hash plus a role/tenant snapshot. Returns the +// raw token -- the only time it's ever available in plaintext again +// after this call. +func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) { + raw, hash, err := newOpaqueToken() + if err != nil { + return "", err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + uuid.NewString(), userID, tenantID, string(role), hash, time.Now().Add(ttl)) + if err != nil { + return "", err + } + return raw, nil +} + +// GetSession looks up an already-hashed lookup key rather than a raw +// token -- see authorizer.go, the only caller, which re-derives the +// hash from whatever the request presented before calling this. +// Deliberately does not delete an expired row itself (that's a plain +// SELECT with no side effect); the goal here is a fast, obviously- +// correct read path, not a lookup that also mutates state, so +// expired-session cleanup is a separate, simpler concern. +func (s *Store) GetSession(ctx context.Context, tokenHash string) (*Session, error) { + var sess Session + var role string + err := s.pool.QueryRow(ctx, ` + SELECT user_id, tenant_id, role, expires_at + FROM local_sessions WHERE token_hash = $1`, tokenHash). + Scan(&sess.UserID, &sess.TenantID, &role, &sess.ExpiresAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + sess.Role = authz.Role(role) + if sess.ExpiresAt.Before(time.Now()) { + return nil, ErrNotFound + } + return &sess, nil +} + +// DeleteSessionByHash backs logout -- a no-op (not an error) if the +// session is already gone, matching logout's own "always succeeds" +// posture (handler.go's handleLogout). +func (s *Store) DeleteSessionByHash(ctx context.Context, tokenHash string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM local_sessions WHERE token_hash = $1`, tokenHash) + return err +} + +// isUniqueViolation checks for Postgres error code 23505 (unique_violation), +// same pgconn.PgError.Code pattern rbacstore.go's SetDataSourceCredentials +// already uses for 22P02. +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/api/localauth/store_integration_test.go b/api/localauth/store_integration_test.go new file mode 100644 index 0000000..33e2652 --- /dev/null +++ b/api/localauth/store_integration_test.go @@ -0,0 +1,157 @@ +// Exercises the actual parameterized SQL in store.go against a real +// Postgres -- handler_test.go's fakeStore is hand-written to mimic this +// SQL's behavior, but can't catch a real gap like a typo in a WHERE +// clause, a wrong column name, or (the specific thing worth testing +// here) whether 0040/0041's schema/FK/CHECK constraints actually hold +// the shape this package assumes. Same "skip unless a live-Postgres env +// var is set" convention as api/dashboards/store_integration_test.go. +// +// Skipped unless LOCALAUTH_TEST_POSTGRES_ADDR is set; run via: +// +// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \ +// -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ +// -e LOCALAUTH_TEST_POSTGRES_PASSWORD=sentry-dev-only \ +// golang:1.25-alpine go test ./localauth/... -run Integration -v +package localauth + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/sentry/sentry/api/authz" +) + +func integrationStore(t *testing.T) *Store { + t.Helper() + addr := os.Getenv("LOCALAUTH_TEST_POSTGRES_ADDR") + if addr == "" { + t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") + } + password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD") + dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("opening pool: %v", err) + } + t.Cleanup(pool.Close) + return NewStore(pool) +} + +func testUsername(t *testing.T) string { + t.Helper() + return "test-" + uuid.NewString()[:8] +} + +func TestIntegrationCreateAndLoginUser(t *testing.T) { + store := integrationStore(t) + ctx := context.Background() + username := testUsername(t) + + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hashing password: %v", err) + } + created, err := store.CreateUser(ctx, username, hash, authz.RoleEditor) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) }) + + got, gotHash, err := store.GetUserForLogin(ctx, username) + if err != nil { + t.Fatalf("GetUserForLogin: %v", err) + } + if got.ID != created.ID || got.Role != authz.RoleEditor { + t.Errorf("GetUserForLogin = %+v, want id=%s role=editor", got, created.ID) + } + if !ComparePassword(gotHash, "correct horse battery staple") { + t.Errorf("stored hash does not verify against the original password") + } +} + +func TestIntegrationDuplicateUsernameRejected(t *testing.T) { + store := integrationStore(t) + ctx := context.Background() + username := testUsername(t) + + hash, _ := HashPassword("password1") + created, err := store.CreateUser(ctx, username, hash, authz.RoleViewer) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) }) + + if _, err := store.CreateUser(ctx, username, hash, authz.RoleViewer); !errors.Is(err, ErrUsernameTaken) { + t.Fatalf("second CreateUser with the same username: err = %v, want ErrUsernameTaken", err) + } +} + +func TestIntegrationSessionRoundTripAndExpiry(t *testing.T) { + store := integrationStore(t) + ctx := context.Background() + username := testUsername(t) + + hash, _ := HashPassword("password1") + user, err := store.CreateUser(ctx, username, hash, authz.RoleAdmin) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) }) + + raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + sess, err := store.GetSession(ctx, hashToken(raw)) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if sess.UserID != user.ID || sess.Role != authz.RoleAdmin { + t.Errorf("GetSession = %+v, want user_id=%s role=admin", sess, user.ID) + } + + // An already-expired session (negative TTL) must not validate -- + // exercises the real expires_at comparison against Postgres's own + // now(), not just Go's clock. + expiredRaw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, -time.Hour) + if err != nil { + t.Fatalf("CreateSession (expired): %v", err) + } + if _, err := store.GetSession(ctx, hashToken(expiredRaw)); !errors.Is(err, ErrNotFound) { + t.Errorf("GetSession on an expired session: err = %v, want ErrNotFound", err) + } +} + +func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) { + store := integrationStore(t) + ctx := context.Background() + username := testUsername(t) + + hash, _ := HashPassword("password1") + user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) }) + + raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + newHash, _ := HashPassword("a-new-password") + if err := store.SetPasswordHash(ctx, user.ID, newHash); err != nil { + t.Fatalf("SetPasswordHash: %v", err) + } + + if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) { + t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err) + } +} diff --git a/api/localauth/token.go b/api/localauth/token.go new file mode 100644 index 0000000..5ff14b7 --- /dev/null +++ b/api/localauth/token.go @@ -0,0 +1,37 @@ +package localauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" +) + +// newOpaqueToken returns a fresh session credential: raw is what's set +// in the cookie/returned to the caller (base64url, URL/cookie-safe), +// hash is what's stored in local_sessions.token_hash. Only the hash is +// ever persisted -- same reasoning 0034_create_ingest_credentials.sql +// gives for hashing its own bearer tokens: the server only ever needs +// to check "does the presented value match," never recover the raw +// value. +func newOpaqueToken() (raw, hash string, err error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", "", err + } + raw = base64.RawURLEncoding.EncodeToString(buf) + return raw, hashToken(raw), nil +} + +// hashToken re-derives a token's hash from a raw value a caller +// presents (Authorization header or cookie), for lookup against +// local_sessions.token_hash. Plain SHA-256, not bcrypt: unlike a +// password, a session token is already high-entropy random data, not +// something an attacker could feasibly brute-force offline even from a +// leaked hash, so there's no need for bcrypt's deliberate slowness here +// -- alerting/internal/sessioncheck validates sessions on every request +// and does the same plain hash, with no bcrypt dependency at all. +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/cli/cmd/sentryctl/cmd_users.go b/cli/cmd/sentryctl/cmd_users.go new file mode 100644 index 0000000..961e3ef --- /dev/null +++ b/cli/cmd/sentryctl/cmd_users.go @@ -0,0 +1,205 @@ +// Command surface for api/localauth -- single-tenant mode's local +// username/password login and user manager (see /docs -- deployment +// runbook, and api/localauth's package doc comment for the full +// feature). Same list/create/delete shape as agents/dashboards, plus a +// "login" subcommand: unlike every other resource this CLI manages, +// there's no way to get a first SENTRYCTL_TOKEN without one. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "sentryctl users: expected a subcommand (login, list, create, delete, reset-password)") + return 1 + } + apiURL, rest := extractAPIFlag(args[1:], os.Getenv) + token := resolveToken(os.Getenv) + + switch args[0] { + case "login": + if len(rest) == 0 { + fmt.Fprintln(stderr, "sentryctl users login: missing username") + return 1 + } + return cmdUsersLogin(rest[0], rest[1:], apiURL, stdin, stdout, stderr) + case "list": + return httpGetJSON(apiURL, "/auth/users", token, stdout, stderr) + case "create": + if len(rest) == 0 { + fmt.Fprintln(stderr, "sentryctl users create: missing username") + return 1 + } + return cmdUsersCreate(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr) + case "delete": + if len(rest) == 0 { + fmt.Fprintln(stderr, "sentryctl users delete: missing user id") + return 1 + } + return httpMutateNoBody(http.MethodDelete, apiURL, "/auth/users/"+rest[0], token, "", "user deleted", stdout, stderr) + case "reset-password": + if len(rest) == 0 { + fmt.Fprintln(stderr, "sentryctl users reset-password: missing user id") + return 1 + } + return cmdUsersResetPassword(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr) + default: + fmt.Fprintf(stderr, "sentryctl users: unknown subcommand %q (want login, list, create, delete, reset-password)\n", args[0]) + return 1 + } +} + +// extractPasswordStdinFlag pulls the boolean --password-stdin flag out +// of args if present -- same "walk args, splice out the one flag this +// caller cares about" shape extractAPIFlag already uses at the +// top-level dispatch layer. Unlike the --password flag this +// replaced (security-audit finding L-4), this flag never carries the +// secret itself -- only readPasswordFromStdin's caller decides to +// actually read one, same "docker login --password-stdin" convention, +// chosen over inventing a new one: a plaintext password passed as a CLI +// argument is visible to any other local user via `ps`/ +// `/proc//cmdline` and typically lands in shell history too. +func extractPasswordStdinFlag(args []string) (useStdin bool, rest []string) { + for _, a := range args { + if a == "--password-stdin" { + useStdin = true + continue + } + rest = append(rest, a) + } + return useStdin, rest +} + +// readPasswordFromStdin reads a single line from stdin. Not masked +// (this codebase has no terminal/raw-mode dependency to draw on -- see +// resolveToken's doc comment for the same tradeoff already accepted for +// SENTRYCTL_TOKEN); pipe the value in (`echo "$PW" | sentryctl users +// login admin`) rather than typing it at an interactive terminal where +// that matters. +func readPasswordFromStdin(stdin io.Reader) (string, error) { + line, err := bufio.NewReader(stdin).ReadString('\n') + if err != nil && line == "" { + return "", err + } + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +type loginRequestBody struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginResponseBody struct { + Token string `json:"token"` + Error string `json:"error"` +} + +// cmdUsersLogin prints only the raw token to stdout on success (nothing +// else) -- deliberately pipeable: `export SENTRYCTL_TOKEN=$(sentryctl +// users login admin)`. +func cmdUsersLogin(username string, _ []string, apiURL string, stdin io.Reader, stdout, stderr io.Writer) int { + password, err := readPasswordFromStdin(stdin) + if err != nil { + fmt.Fprintf(stderr, "reading password: %v\n", err) + return 1 + } + + body, err := json.Marshal(loginRequestBody{Username: username, Password: password}) + if err != nil { + fmt.Fprintf(stderr, "encoding request: %v\n", err) + return 1 + } + req, err := http.NewRequest(http.MethodPost, apiURL+"/auth/login", strings.NewReader(string(body))) + if err != nil { + fmt.Fprintf(stderr, "building request: %v\n", err) + return 1 + } + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(stderr, "request failed: %v\n", err) + return 1 + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Fprintf(stderr, "reading response: %v\n", err) + return 1 + } + var login loginResponseBody + _ = json.Unmarshal(respBody, &login) + if resp.StatusCode != http.StatusOK { + if login.Error != "" { + fmt.Fprintf(stderr, "login failed: %s\n", login.Error) + } else { + fmt.Fprintf(stderr, "login failed: status %d\n", resp.StatusCode) + } + return 1 + } + fmt.Fprintln(stdout, login.Token) + return 0 +} + +func cmdUsersCreate(username string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int { + role := "editor" + for i := 0; i < len(flagArgs); i++ { + if flagArgs[i] == "--role" && i+1 < len(flagArgs) { + role = flagArgs[i+1] + i++ + continue + } + } + + password, err := readPasswordFromStdin(stdin) + if err != nil { + fmt.Fprintf(stderr, "reading password: %v\n", err) + return 1 + } + + body, err := json.Marshal(struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + }{Username: username, Password: password, Role: role}) + if err != nil { + fmt.Fprintf(stderr, "encoding request: %v\n", err) + return 1 + } + return httpPostJSON(apiURL, "/auth/users", token, string(body), stdout, stderr) +} + +// cmdUsersResetPassword defaults to requesting a server-generated +// random password (empty body -- see api/localauth's handleResetPassword +// doc comment): pass --password-stdin to instead set a specific password +// read from stdin. There is deliberately no --password flag (see +// extractPasswordStdinFlag's doc comment) -- a specific password chosen +// this way must be piped in, never typed as a bare CLI argument. +func cmdUsersResetPassword(id string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int { + useStdin, _ := extractPasswordStdinFlag(flagArgs) + body := "{}" + if useStdin { + password, err := readPasswordFromStdin(stdin) + if err != nil { + fmt.Fprintf(stderr, "reading password: %v\n", err) + return 1 + } + encoded, err := json.Marshal(struct { + Password string `json:"password"` + }{Password: password}) + if err != nil { + fmt.Fprintf(stderr, "encoding request: %v\n", err) + return 1 + } + body = string(encoded) + } + return httpPostJSON(apiURL, "/auth/users/"+id+"/reset-password", token, body, stdout, stderr) +} diff --git a/cli/cmd/sentryctl/cmd_users_test.go b/cli/cmd/sentryctl/cmd_users_test.go new file mode 100644 index 0000000..2008127 --- /dev/null +++ b/cli/cmd/sentryctl/cmd_users_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCmdUsersMissingSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cmdUsers(nil, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("code = %d, want 1", code) + } +} + +func TestCmdUsersLoginPrintsOnlyTheToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/auth/login" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body loginRequestBody + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Username != "admin" || body.Password != "s3cret!!" { + t.Errorf("unexpected credentials: %+v", body) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token":"abc123","user_id":"u1","username":"admin","role":"owner"}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("s3cret!!\n"), &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "abc123" { + t.Fatalf("stdout = %q, want exactly the raw token (pipeable into SENTRYCTL_TOKEN)", got) + } +} + +func TestCmdUsersLoginFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid username or password"}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("wrong\n"), &stdout, &stderr) + if code != 1 { + t.Fatalf("code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "invalid username or password") { + t.Fatalf("stderr = %q, want it to surface the server's error message", stderr.String()) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty on failure (nothing pipeable into SENTRYCTL_TOKEN)", stdout.String()) + } +} + +func TestCmdUsersCreateSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/auth/users" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Role != "viewer" { + t.Errorf("role = %q, want viewer (from --role)", body.Role) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":"u2","username":"bob","role":"viewer"}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"create", "bob", "--role", "viewer", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "bob") { + t.Fatalf("stdout = %q, want it to contain the created user", stdout.String()) + } +} + +func TestCmdUsersCreateDefaultsRoleToEditor(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Role string `json:"role"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Role != "editor" { + t.Errorf("role = %q, want editor (the default when --role is omitted)", body.Role) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":"u2","username":"bob","role":"editor"}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"create", "bob", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) + } +} + +func TestCmdUsersDeleteMissingID(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"delete"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("code = %d, want 1", code) + } +} + +func TestCmdUsersDeleteSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/auth/users/u2" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"delete", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) + } +} + +func TestCmdUsersResetPasswordWithGeneratedPassword(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/users/u2/reset-password" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if strings.TrimSpace(string(body)) != "{}" { + t.Errorf("body = %q, want {} (no --password-stdin supplied)", body) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"password":"generated-abc"}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"reset-password", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "generated-abc") { + t.Fatalf("stdout = %q, want it to contain the generated password", stdout.String()) + } +} + +// TestCmdUsersResetPasswordWithStdinPassword is the regression test for +// the security-audit finding that this CLI accepted a plaintext +// --password flag (visible via `ps`/shell history). Setting a +// specific password must go through --password-stdin plus piped input +// instead, never a bare argument. +func TestCmdUsersResetPasswordWithStdinPassword(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Password string `json:"password"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Password != "a-specific-password" { + t.Errorf("password = %q, want the value piped via stdin", body.Password) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdUsers([]string{"reset-password", "u2", "--password-stdin", "--api", srv.URL}, strings.NewReader("a-specific-password\n"), &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) + } +} diff --git a/cli/cmd/sentryctl/httpclient.go b/cli/cmd/sentryctl/httpclient.go index be5d391..67f8fed 100644 --- a/cli/cmd/sentryctl/httpclient.go +++ b/cli/cmd/sentryctl/httpclient.go @@ -115,7 +115,18 @@ func httpMutateNoBody(method, baseURL, path, token, body, successMsg string, std // for callers that construct the body themselves rather than reading it // from a file (agents config set, agents restart). func httpPutJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int { - req, err := http.NewRequest(http.MethodPut, baseURL+path, strings.NewReader(body)) + return httpSendJSON(http.MethodPut, baseURL, path, token, body, stdout, stderr) +} + +// httpPostJSON is httpPutJSON's POST sibling -- for callers creating a +// resource from a body they built themselves rather than reading it +// from a file (users create, users reset-password). +func httpPostJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int { + return httpSendJSON(http.MethodPost, baseURL, path, token, body, stdout, stderr) +} + +func httpSendJSON(method, baseURL, path, token, body string, stdout, stderr io.Writer) int { + req, err := http.NewRequest(method, baseURL+path, strings.NewReader(body)) if err != nil { fmt.Fprintf(stderr, "building request: %v\n", err) return 1 diff --git a/cli/cmd/sentryctl/main.go b/cli/cmd/sentryctl/main.go index 315982a..3aecb7f 100644 --- a/cli/cmd/sentryctl/main.go +++ b/cli/cmd/sentryctl/main.go @@ -40,6 +40,8 @@ func run(args []string, stdout, stderr io.Writer) int { return cmdAlerts(args[1:], stdout, stderr) case "agents": return cmdAgents(args[1:], stdout, stderr) + case "users": + return cmdUsers(args[1:], os.Stdin, stdout, stderr) case "-h", "--help", "help": usage(stdout) return 0 @@ -67,6 +69,11 @@ Usage: [--heartbeat-enabled true|false] [--heartbeat-interval-ms N] [--journald-unit UNIT] [--api ] sentryctl agents restart [--yes] [--api ] + sentryctl users login [--password ] [--api ] + sentryctl users list [--api ] + sentryctl users create [--password ] [--role viewer|editor|admin|owner] [--api ] + sentryctl users delete [--api ] + sentryctl users reset-password [--password ] [--api ] Commands: ping Checks that the api service is reachable via GET /healthz. @@ -95,6 +102,17 @@ Commands: as the web UI's edit form. "restart" briefly interrupts log collection on that host and prompts for confirmation unless --yes is given. + users Local username/password login and user management (see + api/localauth -- only meaningful on a deployment with + LOCAL_AUTH_ENABLED set; a 404 on any of these means it + isn't). "login" is the only command that works with no + $SENTRYCTL_TOKEN set yet -- it prints just the raw token + to stdout: `+"`export SENTRYCTL_TOKEN=$(sentryctl users login admin)`"+`. + --password (on any users subcommand) is read from stdin + if omitted -- same shell-history/ps caveat as typing a + credential in any flag, prefer piping it in. + "create"/"list"/"delete"/"reset-password" require an + owner-role token (see RegisterRoutes in api/localauth). --api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset. --alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset. diff --git a/deploy/operator/go.mod b/deploy/operator/go.mod index 1d9cb26..a7514d3 100644 --- a/deploy/operator/go.mod +++ b/deploy/operator/go.mod @@ -45,11 +45,11 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect - golang.org/x/net v0.26.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/deploy/operator/go.sum b/deploy/operator/go.sum index b83e703..2a8a63f 100644 --- a/deploy/operator/go.sum +++ b/deploy/operator/go.sum @@ -121,8 +121,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -131,22 +131,22 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/enterprise/cmd/enterprise-api/main.go b/enterprise/cmd/enterprise-api/main.go index 08536e2..8d85d97 100644 --- a/enterprise/cmd/enterprise-api/main.go +++ b/enterprise/cmd/enterprise-api/main.go @@ -73,6 +73,9 @@ func main() { logger.Error("loading config", "error", err) os.Exit(1) } + for _, w := range cfg.DevCredentialWarnings() { + logger.Warn(w) + } if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { os.Exit(runHealthcheck(cfg.HTTPListenAddr)) diff --git a/enterprise/cmd/enterprise-auth/main.go b/enterprise/cmd/enterprise-auth/main.go index 652decb..574a40a 100644 --- a/enterprise/cmd/enterprise-auth/main.go +++ b/enterprise/cmd/enterprise-auth/main.go @@ -56,6 +56,9 @@ func main() { logger.Error("loading config", "error", err) os.Exit(1) } + for _, w := range cfg.DevCredentialWarnings() { + logger.Warn(w) + } // -mint-service-token issues a RoleService credential and prints it // to stdout, then exits -- an operator bootstrap step (run once, diff --git a/enterprise/cmd/enterprise-ingest/main.go b/enterprise/cmd/enterprise-ingest/main.go index ec8d4d9..fd38fb3 100644 --- a/enterprise/cmd/enterprise-ingest/main.go +++ b/enterprise/cmd/enterprise-ingest/main.go @@ -59,6 +59,9 @@ func main() { logger.Error("loading config", "error", err) os.Exit(1) } + for _, w := range cfg.DevCredentialWarnings() { + logger.Warn(w) + } if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { os.Exit(runHealthcheck(cfg.HTTPListenAddr)) diff --git a/enterprise/go.mod b/enterprise/go.mod index a6aa5d7..1f1ca97 100644 --- a/enterprise/go.mod +++ b/enterprise/go.mod @@ -36,6 +36,7 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.22.0 google.golang.org/grpc v1.83.0 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 @@ -45,7 +46,7 @@ require ( require ( github.com/ClickHouse/ch-go v0.74.0 // indirect github.com/andybalholm/brotli v1.2.2 // indirect - github.com/beevik/etree v1.5.0 // indirect + github.com/beevik/etree v1.6.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect @@ -66,7 +67,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.19.1 // indirect @@ -78,7 +79,7 @@ require ( github.com/paulmach/orb v0.13.0 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/russellhaering/goxmldsig v1.4.0 // indirect + github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/kafka-go v0.4.51 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -86,12 +87,11 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.12 // indirect diff --git a/enterprise/go.sum b/enterprise/go.sum index 0f53b67..b2a0569 100644 --- a/enterprise/go.sum +++ b/enterprise/go.sum @@ -4,9 +4,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1J github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= -github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= -github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= @@ -69,8 +68,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= -github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -79,9 +78,7 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -107,18 +104,15 @@ github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= -github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= +github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= @@ -165,8 +159,8 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUS golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -191,16 +185,16 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -214,10 +208,8 @@ google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4J google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -226,7 +218,6 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= diff --git a/enterprise/internal/apiconfig/apiconfig.go b/enterprise/internal/apiconfig/apiconfig.go index b2e665a..c969590 100644 --- a/enterprise/internal/apiconfig/apiconfig.go +++ b/enterprise/internal/apiconfig/apiconfig.go @@ -87,6 +87,34 @@ type AuditWriterConfig struct { Password string } +// devOnlyCredential/devOnlyAuditWriterCredential are docker-compose.yml's +// zero-config defaults -- see api/internal/config.Config. +// DevCredentialWarnings for the full reasoning (duplicated here per +// this repo's no-shared-code-between-services convention). +const ( + devOnlyCredential = "sentry-dev-only" + devOnlyAuditWriterCredential = "audit-writer-dev-only" +) + +// DevCredentialWarnings reports which configured credentials still +// equal their literal dev-only defaults -- cmd/enterprise-api/main.go +// logs each one loudly at startup. A warning, not a startup-refusing +// error: local dev's zero-config docker-compose.yml path legitimately +// leaves these at their default values. +func (c Config) DevCredentialWarnings() []string { + var warnings []string + if c.ClickHouseAdmin.Password == devOnlyCredential { + warnings = append(warnings, "CLICKHOUSE_ADMIN_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev") + } + if c.Postgres.Password == devOnlyCredential { + warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev") + } + if c.AuditWriter.Password == devOnlyAuditWriterCredential { + warnings = append(warnings, "AUDIT_WRITER_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev") + } + return warnings +} + func Load() (Config, error) { cfg := Config{ HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8083"), diff --git a/enterprise/internal/config/config.go b/enterprise/internal/config/config.go index 0123923..3e84f02 100644 --- a/enterprise/internal/config/config.go +++ b/enterprise/internal/config/config.go @@ -37,6 +37,36 @@ type Config struct { CORSAllowedOrigin string } +// devOnlyCredential is docker-compose.yml's zero-config default for +// every Postgres/ClickHouse password in this repo -- see +// api/internal/config.Config.DevCredentialWarnings for the full +// reasoning (duplicated here per this repo's no-shared-code-between- +// services convention). enterprise-auth also has its own dev-only +// literal for ENTERPRISE_SESSION_SIGNING_KEY, checked alongside it below +// -- per the threat model doc, this is "the single highest-value secret +// in the enterprise deployment," since compromising it lets an attacker +// forge any identity, including the RoleService credential. +const ( + devOnlyCredential = "sentry-dev-only" + devOnlySigningKey = "sentry-dev-only-session-signing-key-32bytes+" +) + +// DevCredentialWarnings reports which configured secrets still equal +// their literal dev-only defaults -- cmd/enterprise-*/main.go logs each +// one loudly at startup. A warning, not a startup-refusing error: local +// dev's zero-config docker-compose.yml path legitimately leaves these +// at their default values. +func (c Config) DevCredentialWarnings() []string { + var warnings []string + if c.Postgres.Password == devOnlyCredential { + warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev") + } + if string(c.SessionSigningKey) == devOnlySigningKey { + warnings = append(warnings, "ENTERPRISE_SESSION_SIGNING_KEY is still the default dev-only value -- this is the single highest-value secret in an enterprise deployment (compromise lets an attacker forge any identity); set a real, random one before this is reachable outside local dev") + } + return warnings +} + type PostgresConfig struct { Addr string Database string diff --git a/enterprise/internal/ingestconfig/ingestconfig.go b/enterprise/internal/ingestconfig/ingestconfig.go index 106a098..059f9a8 100644 --- a/enterprise/internal/ingestconfig/ingestconfig.go +++ b/enterprise/internal/ingestconfig/ingestconfig.go @@ -49,6 +49,25 @@ type BatchConfig struct { FlushIntervalMS int } +// devOnlyCredential is docker-compose.yml's zero-config default for +// every Postgres/ClickHouse password in this repo -- see +// api/internal/config.Config.DevCredentialWarnings for the full +// reasoning (duplicated here per this repo's no-shared-code-between- +// services convention). +const devOnlyCredential = "sentry-dev-only" + +// DevCredentialWarnings reports whether the configured Postgres +// credential still equals the literal dev-only default -- +// cmd/enterprise-ingest/main.go logs it loudly at startup. A warning, +// not a startup-refusing error: local dev's zero-config +// docker-compose.yml path legitimately leaves it at this value. +func (c Config) DevCredentialWarnings() []string { + if c.Postgres.Password == devOnlyCredential { + return []string{"POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev"} + } + return nil +} + func Load() (Config, error) { cfg := Config{ HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8084"), diff --git a/hack/benchmark-fixture/go.mod b/hack/benchmark-fixture/go.mod index 8c05d9d..125a6b0 100644 --- a/hack/benchmark-fixture/go.mod +++ b/hack/benchmark-fixture/go.mod @@ -12,7 +12,7 @@ require ( require ( golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/hack/benchmark-fixture/go.sum b/hack/benchmark-fixture/go.sum index fcc6745..481b598 100644 --- a/hack/benchmark-fixture/go.sum +++ b/hack/benchmark-fixture/go.sum @@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= diff --git a/hack/windows-fixture/go.mod b/hack/windows-fixture/go.mod index 4388c87..4cca78d 100644 --- a/hack/windows-fixture/go.mod +++ b/hack/windows-fixture/go.mod @@ -12,7 +12,7 @@ require ( require ( golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/hack/windows-fixture/go.sum b/hack/windows-fixture/go.sum index fcc6745..481b598 100644 --- a/hack/windows-fixture/go.sum +++ b/hack/windows-fixture/go.sum @@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= diff --git a/ingest/cmd/ingest/main.go b/ingest/cmd/ingest/main.go index e6889c1..24596bb 100644 --- a/ingest/cmd/ingest/main.go +++ b/ingest/cmd/ingest/main.go @@ -69,6 +69,9 @@ func main() { logger.Error("loading config", "error", err) os.Exit(1) } + for _, w := range cfg.DevCredentialWarnings() { + logger.Warn(w) + } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/ingest/internal/agentregistry/agentregistry.go b/ingest/internal/agentregistry/agentregistry.go index cde0a18..ac3c05b 100644 --- a/ingest/internal/agentregistry/agentregistry.go +++ b/ingest/internal/agentregistry/agentregistry.go @@ -34,11 +34,12 @@ const defaultTenantID = "default" // across a module boundary rather than couple two independently // deployable services' builds together. Keep the two in sync by hand. type overrideFields struct { - BatchMaxSize *uint64 `json:"batch_max_size,omitempty"` - BatchFlushIntervalMS *uint64 `json:"batch_flush_interval_ms,omitempty"` - HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"` - HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"` - JournaldUnit *string `json:"journald_unit,omitempty"` + BatchMaxSize *uint64 `json:"batch_max_size,omitempty"` + BatchFlushIntervalMS *uint64 `json:"batch_flush_interval_ms,omitempty"` + HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"` + HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"` + JournaldUnit *string `json:"journald_unit,omitempty"` + ExtraFilePaths []string `json:"extra_file_paths,omitempty"` } type Registry struct { @@ -146,6 +147,7 @@ func (r *Registry) CheckIn(ctx context.Context, tenantID string, info grpcserver HeartbeatEnabled: fields.HeartbeatEnabled, HeartbeatIntervalMS: fields.HeartbeatIntervalMS, JournaldUnit: fields.JournaldUnit, + ExtraFilePaths: fields.ExtraFilePaths, Version: *desiredVersion, } return result, nil diff --git a/ingest/internal/config/config.go b/ingest/internal/config/config.go index db7209d..3e6e77e 100644 --- a/ingest/internal/config/config.go +++ b/ingest/internal/config/config.go @@ -75,6 +75,29 @@ type BatchConfig struct { FlushIntervalMS int } +// devOnlyCredential is docker-compose.yml's zero-config default for +// every Postgres/ClickHouse password in this repo -- see +// api/internal/config.Config.DevCredentialWarnings for the full +// reasoning (duplicated here per this repo's no-shared-code-between- +// services convention). +const devOnlyCredential = "sentry-dev-only" + +// DevCredentialWarnings reports which configured credentials still +// equal the literal dev-only default -- cmd/ingest/main.go logs each +// one loudly at startup. A warning, not a startup-refusing error: local +// dev's zero-config docker-compose.yml path legitimately leaves every +// password at this value. +func (c Config) DevCredentialWarnings() []string { + var warnings []string + if c.ClickHouse.Password == devOnlyCredential { + warnings = append(warnings, "CLICKHOUSE_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev") + } + if c.AgentRegistry.Postgres.Password == devOnlyCredential { + warnings = append(warnings, "AGENT_REGISTRY_POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev") + } + return warnings +} + func Load() (Config, error) { cfg := Config{ GRPC: GRPCConfig{ diff --git a/ingest/internal/grpcserver/server.go b/ingest/internal/grpcserver/server.go index 2e200d6..be8b233 100644 --- a/ingest/internal/grpcserver/server.go +++ b/ingest/internal/grpcserver/server.go @@ -147,7 +147,12 @@ type AgentOverride struct { HeartbeatEnabled *bool HeartbeatIntervalMS *uint64 JournaldUnit *string - Version string + // Extra file paths this agent should tail in addition to its local + // [source] -- see agent_control.proto's DesiredOverride. + // extra_file_paths comment for why this has no "unset" state the + // way the pointer fields above do. + ExtraFilePaths []string + Version string } func New(logger *slog.Logger, grpcCfg config.GRPCConfig, tlsCfg config.TLSConfig, p batchProducer, resolver TenantResolver, agents AgentRegistry) *Server { @@ -298,6 +303,7 @@ func (s *Server) CheckIn(ctx context.Context, req *agentv1.CheckInRequest) (*age HeartbeatEnabled: result.Override.HeartbeatEnabled, HeartbeatIntervalMs: result.Override.HeartbeatIntervalMS, JournaldUnit: result.Override.JournaldUnit, + ExtraFilePaths: result.Override.ExtraFilePaths, Version: result.Override.Version, } } diff --git a/metadata/migrations/0040_add_local_login_to_users.sql b/metadata/migrations/0040_add_local_login_to_users.sql new file mode 100644 index 0000000..8ce0408 --- /dev/null +++ b/metadata/migrations/0040_add_local_login_to_users.sql @@ -0,0 +1,18 @@ +-- Local username/password login (single-tenant deployments with no +-- enterprise-auth/SSO configured) -- see api/localauth's package doc +-- comment. Reuses 0017's users table and 0020's tenant_memberships +-- rather than a parallel identity model, so a deployment that later +-- turns on enterprise SSO has one identity graph, not two to reconcile. +-- +-- email relaxed to nullable: a local user has no SSO identity to hang +-- an email off of. enterprise/internal/rbacstore.UpsertUserBySSO +-- already requires a non-empty email in Go before ever inserting, so +-- this is a no-op for the SSO path. +ALTER TABLE users + ALTER COLUMN email DROP NOT NULL; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS username TEXT UNIQUE; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS password_hash TEXT; diff --git a/metadata/migrations/0041_create_local_sessions.sql b/metadata/migrations/0041_create_local_sessions.sql new file mode 100644 index 0000000..611547d --- /dev/null +++ b/metadata/migrations/0041_create_local_sessions.sql @@ -0,0 +1,20 @@ +-- Opaque, revocable session tokens for local login -- only the SHA-256 +-- hash of the token is stored, same posture as 0034's +-- ingest_credentials: the server never needs the raw value back, only +-- "does this match." role/tenant_id are a snapshot taken at login, not +-- a live join to tenant_memberships on every request -- api/localauth's +-- Authorizer runs on every HTTP request, so this keeps that path to one +-- indexed lookup. Consequence: a role change doesn't take effect for an +-- existing session until it's revoked/expires and the user logs in +-- again -- api/localauth.Store.SetPasswordHash deliberately revokes a +-- user's sessions for exactly this reason. +CREATE TABLE IF NOT EXISTS local_sessions +( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')), + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +) diff --git a/proto/go.mod b/proto/go.mod index 535ced8..2aac19a 100644 --- a/proto/go.mod +++ b/proto/go.mod @@ -10,6 +10,6 @@ require ( require ( golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect ) diff --git a/proto/go.sum b/proto/go.sum index fcc6745..481b598 100644 --- a/proto/go.sum +++ b/proto/go.sum @@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= diff --git a/proto/sentry/agent/v1/agent_control.pb.go b/proto/sentry/agent/v1/agent_control.pb.go index bfb69e5..86a862e 100644 --- a/proto/sentry/agent/v1/agent_control.pb.go +++ b/proto/sentry/agent/v1/agent_control.pb.go @@ -272,9 +272,20 @@ type DesiredOverride struct { // agent's only obligation is to echo it back as // CheckInRequest.applied_override_version once applied -- it never // interprets the value itself. - Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"` + // Extra file paths this agent should tail in addition to whatever its + // local [source] already is -- never a replacement for the primary + // source (an agent whose local source is journald can still be told + // to also tail a file, and vice versa). Unlike every field above, + // there's no real "unset" state for a list: the web UI/CLI always + // resubmit the complete desired list on every edit (same "PUT + // replaces the whole override" convention every other field already + // follows -- see api/agents/handler.go's handleSetConfig), so an + // empty list unambiguously means "no extra paths right now," not + // "don't touch this." + ExtraFilePaths []string `protobuf:"bytes,7,rep,name=extra_file_paths,json=extraFilePaths,proto3" json:"extra_file_paths,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DesiredOverride) Reset() { @@ -349,6 +360,13 @@ func (x *DesiredOverride) GetVersion() string { return "" } +func (x *DesiredOverride) GetExtraFilePaths() []string { + if x != nil { + return x.ExtraFilePaths + } + return nil +} + type CheckInResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // False when no override has ever been set for this agent -- it @@ -442,14 +460,15 @@ const file_sentry_agent_v1_agent_control_proto_rawDesc = "" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12F\n" + "\x0ecurrent_config\x18\x03 \x01(\v2\x1f.sentry.agent.v1.ReportedConfigR\rcurrentConfig\x128\n" + - "\x18applied_override_version\x18\x04 \x01(\tR\x16appliedOverrideVersion\"\x98\x03\n" + + "\x18applied_override_version\x18\x04 \x01(\tR\x16appliedOverrideVersion\"\xc2\x03\n" + "\x0fDesiredOverride\x12)\n" + "\x0ebatch_max_size\x18\x01 \x01(\x04H\x00R\fbatchMaxSize\x88\x01\x01\x12:\n" + "\x17batch_flush_interval_ms\x18\x02 \x01(\x04H\x01R\x14batchFlushIntervalMs\x88\x01\x01\x120\n" + "\x11heartbeat_enabled\x18\x03 \x01(\bH\x02R\x10heartbeatEnabled\x88\x01\x01\x127\n" + "\x15heartbeat_interval_ms\x18\x04 \x01(\x04H\x03R\x13heartbeatIntervalMs\x88\x01\x01\x12(\n" + "\rjournald_unit\x18\x05 \x01(\tH\x04R\fjournaldUnit\x88\x01\x01\x12\x18\n" + - "\aversion\x18\x06 \x01(\tR\aversionB\x11\n" + + "\aversion\x18\x06 \x01(\tR\aversion\x12(\n" + + "\x10extra_file_paths\x18\a \x03(\tR\x0eextraFilePathsB\x11\n" + "\x0f_batch_max_sizeB\x1a\n" + "\x18_batch_flush_interval_msB\x14\n" + "\x12_heartbeat_enabledB\x18\n" + diff --git a/proto/sentry/agent/v1/agent_control.proto b/proto/sentry/agent/v1/agent_control.proto index 14199d6..f565c03 100644 --- a/proto/sentry/agent/v1/agent_control.proto +++ b/proto/sentry/agent/v1/agent_control.proto @@ -69,6 +69,17 @@ message DesiredOverride { // CheckInRequest.applied_override_version once applied -- it never // interprets the value itself. string version = 6; + // Extra file paths this agent should tail in addition to whatever its + // local [source] already is -- never a replacement for the primary + // source (an agent whose local source is journald can still be told + // to also tail a file, and vice versa). Unlike every field above, + // there's no real "unset" state for a list: the web UI/CLI always + // resubmit the complete desired list on every edit (same "PUT + // replaces the whole override" convention every other field already + // follows -- see api/agents/handler.go's handleSetConfig), so an + // empty list unambiguously means "no extra paths right now," not + // "don't touch this." + repeated string extra_file_paths = 7; } // AgentCommand is a one-shot action, not a persistent desired state like diff --git a/search/Cargo.lock b/search/Cargo.lock index 13e778e..fd77888 100644 --- a/search/Cargo.lock +++ b/search/Cargo.lock @@ -344,7 +344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -534,9 +534,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -698,7 +698,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1089,7 +1089,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1548,7 +1548,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1954,7 +1954,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/terraform/go.mod b/terraform/go.mod index 172478d..aab46e1 100644 --- a/terraform/go.mod +++ b/terraform/go.mod @@ -48,15 +48,15 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.18.1 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/terraform/go.sum b/terraform/go.sum index d54160d..43b96aa 100644 --- a/terraform/go.sum +++ b/terraform/go.sum @@ -163,34 +163,34 @@ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -202,32 +202,32 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/web/Dockerfile b/web/Dockerfile index e84f4a7..60d2735 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -22,9 +22,16 @@ COPY . . ARG VITE_API_BASE_URL=http://localhost:8080 ARG VITE_ALERTING_API_BASE_URL=http://localhost:8081 ARG VITE_ENTERPRISE_AUTH_BASE_URL +# Off by default -- local dev's plain WithCORS (wildcard +# Access-Control-Allow-Origin) can't be combined with a credentialed +# fetch at all, so api.ts must not send cookies unless the deployment +# actually turned local auth on server-side too (LOCAL_AUTH_ENABLED). +# See api.ts's requestFrom/alertingRequest doc comment. +ARG VITE_LOCAL_AUTH_ENABLED=false ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ENV VITE_ALERTING_API_BASE_URL=${VITE_ALERTING_API_BASE_URL} ENV VITE_ENTERPRISE_AUTH_BASE_URL=${VITE_ENTERPRISE_AUTH_BASE_URL} +ENV VITE_LOCAL_AUTH_ENABLED=${VITE_LOCAL_AUTH_ENABLED} RUN npm run build # Not distroless: serving a static SPA needs *some* HTTP server, and diff --git a/web/nginx.conf b/web/nginx.conf index b72f170..1950aa5 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -3,6 +3,43 @@ server { root /usr/share/nginx/html; index index.html; + # Security-audit remediation (M-3): baseline browser security headers, + # absent entirely before this. HSTS/nosniff/frame-options/referrer- + # policy/permissions-policy carry no functional risk to this app and + # are unconditionally safe to add. + # + # CSP is the one directive that needed real care rather than a + # copy-pasted strict default: adapter-static's own build output + # (web/build/index.html) genuinely contains two inline @@ -14,25 +64,42 @@ -
- (paletteOpen = true)} - mobileOpen={mobileNavOpen} - onCloseMobile={() => (mobileNavOpen = false)} - /> -
- -
- {@render children()} +{#if isLoginPage} + {@render children()} +{:else if initialCheckDone && authorized} +
+ (paletteOpen = true)} + mobileOpen={mobileNavOpen} + onCloseMobile={() => (mobileNavOpen = false)} + /> +
+ +
+ {@render children()} +
-
- + +{:else} + +

Loading…

+{/if} diff --git a/web/src/routes/hosts/+page.ts b/web/src/routes/hosts/+page.ts new file mode 100644 index 0000000..fcc5385 --- /dev/null +++ b/web/src/routes/hosts/+page.ts @@ -0,0 +1,3 @@ +// No route params, data comes from a client-side fetch -- same shape as +// the agents list page's +page.ts. +export const prerender = true; diff --git a/web/src/routes/hosts/[host]/+page.svelte b/web/src/routes/hosts/[host]/+page.svelte new file mode 100644 index 0000000..a24a87f --- /dev/null +++ b/web/src/routes/hosts/[host]/+page.svelte @@ -0,0 +1,186 @@ + + +
+ ← Hosts +

{host}

+ + {#if loading} + + {:else if error} +

Error: {error}

+ {:else if !metrics} +

No metrics samples for this host yet.

+ {:else} +

Last sample {relativeTime(metrics.timestamp)}.

+ +
+
+
OS
+
{metrics.osName}
+
Kernel
+
{metrics.kernelVersion}
+
Architecture
+
{metrics.arch}
+
Uptime
+
{formatUptime(metrics.uptimeSeconds)}
+
IPv4
+
{metrics.ipv4Addresses.length > 0 ? metrics.ipv4Addresses.join(', ') : '—'}
+
IPv6
+
{metrics.ipv6Addresses.length > 0 ? metrics.ipv6Addresses.join(', ') : '—'}
+
+
+ +
+ +
{metrics.cpuPercent.toFixed(1)}%
+
+
+
+
{metrics.cpuCores} core{metrics.cpuCores === 1 ? '' : 's'}
+
+ + +
{percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%
+
+
+
+
{formatBytes(metrics.memUsedBytes)} / {formatBytes(metrics.memTotalBytes)}
+
+ + +
{percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%
+
+
+
+
{formatBytes(metrics.diskUsedBytes)} / {formatBytes(metrics.diskTotalBytes)}
+
+
+ {/if} +
+ + diff --git a/web/src/routes/hosts/[host]/+page.ts b/web/src/routes/hosts/[host]/+page.ts new file mode 100644 index 0000000..ce4bc30 --- /dev/null +++ b/web/src/routes/hosts/[host]/+page.ts @@ -0,0 +1,4 @@ +// The host param doesn't exist at build time -- same reasoning as +// agents/[host]/+page.ts. +export const prerender = false; +export const ssr = false; diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..6959f15 --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,109 @@ + + +
+

Sign in

+
+ {#if error} +

{error}

+ {/if} + + + +
+
+ + diff --git a/web/src/routes/login/+page.ts b/web/src/routes/login/+page.ts new file mode 100644 index 0000000..89199d5 --- /dev/null +++ b/web/src/routes/login/+page.ts @@ -0,0 +1,4 @@ +// Client-only, same reasoning as select-tenant/+page.ts -- nothing to +// prerender server-side, everything here is a fetch against api's +// /auth/login. +export const prerender = true; diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index ebdabf8..996a442 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -1,5 +1,17 @@