Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.1] - 2026-04-17

Hardening patch after the v2.0.0 release. No new features, no breaking changes. Three fixes bundled into one coherent "post-v2.0.0 durability" release.

### Security

- **Notifier config secrets wrapped in `SecretString`**. `TelegramNotifierConfig.bot_token`, `MattermostNotifierConfig.webhook_url`, `WebhookNotifierConfig.url`, `WebhookNotifierConfig.headers` values, and `SmtpConfig.password` are now `SecretString` rather than raw `String`. A `format!("{:?}", config)` or a tracing context that captures the parsed config renders `[REDACTED]` instead of the actual value. Prior to this, the token lived as a plain `String` until notifier construction time, so any debug-log or error context that carried the config body would leak the secret. A regression-guard unit test (`notifier_config_debug_never_leaks_secrets`) runs canary values through each notifier config's `Debug` output and asserts none appear.

### Fixed

- **Migration error on v1.x configs now points to MIGRATION.md and covers rollback** (D-doc-1). Loading a v1.x `victorialogs.url`-shape config against v2.x used to emit a serde-flavoured error that referenced the CHANGELOG only. The new message leads with a human-readable "Configuration incompatible with valerter v2.0.0", includes the before/after YAML diff, a direct link to `MIGRATION.md`, and a rollback hint pointing at the GitHub releases page.

- **`valerter_vl_source_up` gauge debounced to 3 consecutive failures** (D-vl-obs-1). The gauge used to flip to `0` on any single HTTP 5xx, connection error, or mid-stream EOF, which made sources behind a flaky load balancer flap their reachability state and page operators on transient events. The flip is now gated by `VL_SOURCE_UP_FAILURE_THRESHOLD = 3` (fixed, not configurable): three consecutive failures before `0`, any single success resets the counter back to `1` and re-arms the debounce. Contract unchanged for persistently-down sources.

## [2.0.0] - 2026-04-16

### Security advisory
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "valerter"
version = "2.0.0"
version = "2.0.1"
edition = "2024"
description = "Real-time log alerting for VictoriaLogs"
license = "Apache-2.0"
Expand Down
75 changes: 66 additions & 9 deletions src/config/notifiers.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Notifier configurations (Mattermost, Webhook, Email).

use super::secret::SecretString;
use serde::Deserialize;
use std::collections::HashMap;

Expand All @@ -26,7 +27,7 @@ pub enum NotifierConfig {
#[serde(deny_unknown_fields)]
pub struct MattermostNotifierConfig {
/// Webhook URL (supports `${ENV_VAR}` substitution).
pub webhook_url: String,
pub webhook_url: SecretString,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
Expand All @@ -40,11 +41,11 @@ pub struct MattermostNotifierConfig {
#[serde(deny_unknown_fields)]
pub struct WebhookNotifierConfig {
/// Target URL (supports `${ENV_VAR}` substitution).
pub url: String,
pub url: SecretString,
#[serde(default = "default_post")]
pub method: String,
#[serde(default)]
pub headers: HashMap<String, String>,
pub headers: HashMap<String, SecretString>,
#[serde(default)]
pub body_template: Option<String>,
}
Expand All @@ -68,7 +69,7 @@ pub struct EmailNotifierConfig {
#[serde(deny_unknown_fields)]
pub struct TelegramNotifierConfig {
/// Bot API token (supports `${ENV_VAR}` substitution).
pub bot_token: String,
pub bot_token: SecretString,
/// One or more Telegram chat IDs to send messages to. Must be non-empty.
pub chat_ids: Vec<String>,
/// Telegram parse mode for the message text. Defaults to `HTML`.
Expand All @@ -94,7 +95,7 @@ pub struct SmtpConfig {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
pub password: Option<SecretString>,
#[serde(default)]
pub tls: TlsMode,
#[serde(default = "default_true")]
Expand Down Expand Up @@ -133,7 +134,7 @@ mod tests {
let config: NotifierConfig = serde_yaml::from_str(yaml).unwrap();
match config {
NotifierConfig::Mattermost(cfg) => {
assert_eq!(cfg.webhook_url, "https://example.com/hooks/test");
assert_eq!(cfg.webhook_url.expose(), "https://example.com/hooks/test");
assert!(cfg.channel.is_none());
}
_ => panic!("Expected Mattermost variant"),
Expand Down Expand Up @@ -194,7 +195,7 @@ mod tests {
let config: NotifierConfig = serde_yaml::from_str(yaml).unwrap();
match config {
NotifierConfig::Telegram(cfg) => {
assert_eq!(cfg.bot_token, "${TELEGRAM_BOT_TOKEN}");
assert_eq!(cfg.bot_token.expose(), "${TELEGRAM_BOT_TOKEN}");
assert_eq!(cfg.chat_ids.len(), 2);
assert_eq!(cfg.parse_mode.as_deref(), Some("HTML"));
assert_eq!(cfg.disable_notification, Some(false));
Expand All @@ -215,7 +216,7 @@ mod tests {
let config: NotifierConfig = serde_yaml::from_str(yaml).unwrap();
match config {
NotifierConfig::Telegram(cfg) => {
assert_eq!(cfg.bot_token, "token123");
assert_eq!(cfg.bot_token.expose(), "token123");
assert_eq!(cfg.chat_ids, vec!["-100".to_string()]);
assert!(cfg.parse_mode.is_none());
assert!(cfg.disable_notification.is_none());
Expand Down Expand Up @@ -305,7 +306,7 @@ mod tests {
let config: NotifierConfig = serde_yaml::from_str(yaml).unwrap();
match config {
NotifierConfig::Webhook(cfg) => {
assert_eq!(cfg.url, "https://api.example.com/alerts");
assert_eq!(cfg.url.expose(), "https://api.example.com/alerts");
assert_eq!(cfg.method, "PUT");
assert_eq!(cfg.headers.len(), 2);
assert!(cfg.body_template.is_some());
Expand Down Expand Up @@ -556,4 +557,60 @@ mod tests {
assert_eq!(config.destinations.len(), 1);
assert_eq!(config.destinations[0], "mattermost-infra");
}

/// Regression guard: notifier configs must never leak secrets through
/// `Debug` (which is what `tracing::debug!`, `anyhow::Error` contexts,
/// and support-ticket config dumps all end up calling).
#[test]
fn notifier_config_debug_never_leaks_secrets() {
const CANARY_TOKEN: &str = "CANARY_BOT_TOKEN_abc123xyz";
const CANARY_URL: &str = "https://canary.example.com/hooks/SECRET-hooks-path";
const CANARY_HEADER: &str = "CANARY_BEARER_SECRET_xyz789";
const CANARY_PASSWORD: &str = "CANARY_SMTP_PASSWORD_qwerty";

let telegram_yaml = format!(
"type: telegram\nbot_token: \"{}\"\nchat_ids: [\"-100\"]\n",
CANARY_TOKEN
);
let mattermost_yaml = format!("type: mattermost\nwebhook_url: \"{}\"\n", CANARY_URL);
let webhook_yaml = format!(
"type: webhook\nurl: \"{}\"\nheaders:\n Authorization: \"Bearer {}\"\n",
CANARY_URL, CANARY_HEADER
);
let email_yaml = format!(
"type: email\nsmtp:\n host: smtp.example.com\n port: 587\n username: u\n password: \"{}\"\nfrom: f@example.com\nto: [t@example.com]\nsubject_template: s\n",
CANARY_PASSWORD
);

let configs: Vec<(&str, NotifierConfig)> = vec![
("telegram", serde_yaml::from_str(&telegram_yaml).unwrap()),
(
"mattermost",
serde_yaml::from_str(&mattermost_yaml).unwrap(),
),
("webhook", serde_yaml::from_str(&webhook_yaml).unwrap()),
("email", serde_yaml::from_str(&email_yaml).unwrap()),
];

let canaries = [CANARY_TOKEN, CANARY_URL, CANARY_HEADER, CANARY_PASSWORD];

for (kind, cfg) in &configs {
let dbg = format!("{:?}", cfg);
for canary in &canaries {
assert!(
!dbg.contains(canary),
"{} Debug leaked secret canary '{}': {}",
kind,
canary,
dbg
);
}
assert!(
dbg.contains("REDACTED"),
"{} Debug should render secrets as [REDACTED]: {}",
kind,
dbg
);
}
}
}
6 changes: 3 additions & 3 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct LegacyVictoriaLogsConfig {

/// Migration error text pointing users from the v1 single-URL shape to the
/// v2 map shape. Exposed so tests can assert wording.
pub(crate) const LEGACY_VL_MIGRATION_MESSAGE: &str = "`victorialogs` is now a map of named sources (breaking change in v2.0.0).\n\nMigrate from:\n victorialogs:\n url: \"http://...\"\n basic_auth:\n username: \"u\"\n password: \"p\"\nTo:\n victorialogs:\n default:\n url: \"http://...\"\n basic_auth:\n username: \"u\"\n password: \"p\"\n\nThen optionally target sources per rule via `vl_sources: [default]` (or omit to fan out across all sources). See CHANGELOG v2.0.0 for the full migration note.";
pub(crate) const LEGACY_VL_MIGRATION_MESSAGE: &str = "\nConfiguration incompatible with valerter v2.0.0.\n\nYour config uses the v1.x single-URL shape (`victorialogs.url`), replaced by a map of named sources in v2.\n\nMigrate from:\n victorialogs:\n url: \"http://...\"\n basic_auth:\n username: \"u\"\n password: \"p\"\nTo:\n victorialogs:\n default:\n url: \"http://...\"\n basic_auth:\n username: \"u\"\n password: \"p\"\n\nThen optionally target sources per rule via `vl_sources: [default]` (or omit to fan out across all sources).\n\nFull migration guide: https://github.com/fxthiry/valerter/blob/main/MIGRATION.md\nRollback: install the last v1.x release from https://github.com/fxthiry/valerter/releases";

/// Deserialize `victorialogs` as `BTreeMap<String, VlSourceConfig>`, but
/// emit a migration-oriented error when the legacy single-object shape
Expand Down Expand Up @@ -615,15 +615,15 @@ impl Config {
for (name, notifier) in notifiers {
match notifier {
super::notifiers::NotifierConfig::Mattermost(cfg) => {
if let Err(e) = validate_url(&cfg.webhook_url) {
if let Err(e) = validate_url(cfg.webhook_url.expose()) {
errors.push(ConfigError::ValidationError(format!(
"notifier '{}': webhook_url: {}",
name, e
)));
}
}
super::notifiers::NotifierConfig::Webhook(cfg) => {
if let Err(e) = validate_url(&cfg.url) {
if let Err(e) = validate_url(cfg.url.expose()) {
errors.push(ConfigError::ValidationError(format!(
"notifier '{}': url: {}",
name, e
Expand Down
12 changes: 6 additions & 6 deletions src/notify/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ impl EmailNotifier {
.smtp
.password
.as_ref()
.map(|p| resolve_env_vars(p))
.map(|p| resolve_env_vars(p.expose()))
.transpose()
.map_err(|e| ConfigError::InvalidNotifier {
name: name.to_string(),
Expand Down Expand Up @@ -661,7 +661,7 @@ impl std::fmt::Debug for EmailNotifier {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{EmailNotifierConfig, SmtpConfig, TlsMode};
use crate::config::{EmailNotifierConfig, SecretString, SmtpConfig, TlsMode};
use crate::template::RenderedMessage;
use serial_test::serial;
use std::sync::Mutex;
Expand Down Expand Up @@ -938,7 +938,7 @@ mod tests {
fn from_config_fails_with_password_without_username() {
let mut config = make_test_config();
config.smtp.username = None;
config.smtp.password = Some("pass".to_string());
config.smtp.password = Some(SecretString::new("pass".to_string()));

let result = EmailNotifier::from_config("no-username", &config, &test_config_dir());

Expand All @@ -964,7 +964,7 @@ mod tests {
|| {
let mut config = make_test_config();
config.smtp.username = Some("${TEST_SMTP_USER}".to_string());
config.smtp.password = Some("${TEST_SMTP_PASS}".to_string());
config.smtp.password = Some(SecretString::new("${TEST_SMTP_PASS}".to_string()));

let result = EmailNotifier::from_config("env-creds", &config, &test_config_dir());

Expand All @@ -985,7 +985,7 @@ mod tests {
temp_env::with_var("UNDEFINED_SMTP_VAR", None::<&str>, || {
let mut config = make_test_config();
config.smtp.username = Some("${UNDEFINED_SMTP_VAR}".to_string());
config.smtp.password = Some("somepass".to_string());
config.smtp.password = Some(SecretString::new("somepass".to_string()));

let result = EmailNotifier::from_config("bad-env", &config, &test_config_dir());

Expand Down Expand Up @@ -1168,7 +1168,7 @@ mod tests {
|| {
let mut config = make_test_config();
config.smtp.username = Some("${TEST_DBG_USER}".to_string());
config.smtp.password = Some("${TEST_DBG_PASS}".to_string());
config.smtp.password = Some(SecretString::new("${TEST_DBG_PASS}".to_string()));

let notifier =
EmailNotifier::from_config("cred-email", &config, &test_config_dir()).unwrap();
Expand Down
27 changes: 14 additions & 13 deletions src/notify/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,20 @@ impl NotifierRegistry {
match config {
NotifierConfig::Mattermost(mm_config) => {
// Resolve environment variables in webhook_url
let resolved_url = resolve_env_vars(&mm_config.webhook_url).map_err(|e| {
// Track env var resolution failures for monitoring (Fix M1)
metrics::counter!(
"valerter_notifier_config_errors_total",
"notifier" => name.to_string(),
"error_type" => "env_var_resolution"
)
.increment(1);
ConfigError::InvalidNotifier {
name: name.to_string(),
message: format!("webhook_url: {}", e),
}
})?;
let resolved_url =
resolve_env_vars(mm_config.webhook_url.expose()).map_err(|e| {
// Track env var resolution failures for monitoring (Fix M1)
metrics::counter!(
"valerter_notifier_config_errors_total",
"notifier" => name.to_string(),
"error_type" => "env_var_resolution"
)
.increment(1);
ConfigError::InvalidNotifier {
name: name.to_string(),
message: format!("webhook_url: {}", e),
}
})?;

let notifier = MattermostNotifier::with_options(
name.to_string(),
Expand Down
15 changes: 8 additions & 7 deletions src/notify/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,12 @@ impl TelegramNotifier {
});
}

let resolved_token =
resolve_env_vars(&config.bot_token).map_err(|e| ConfigError::InvalidNotifier {
let resolved_token = resolve_env_vars(config.bot_token.expose()).map_err(|e| {
ConfigError::InvalidNotifier {
name: name.to_string(),
message: format!("bot_token: {}", e),
})?;
}
})?;
if resolved_token.trim().is_empty() {
return Err(ConfigError::InvalidNotifier {
name: name.to_string(),
Expand Down Expand Up @@ -537,7 +538,7 @@ mod tests {

fn config_with(chat_ids: Vec<String>) -> TelegramNotifierConfig {
TelegramNotifierConfig {
bot_token: "fake-token".to_string(),
bot_token: SecretString::new("fake-token".to_string()),
chat_ids,
parse_mode: None,
disable_notification: None,
Expand Down Expand Up @@ -603,7 +604,7 @@ mod tests {
fn from_config_fails_fast_on_unresolved_env_var() {
let client = reqwest::Client::new();
let mut cfg = config_with(vec!["-100".to_string()]);
cfg.bot_token = "${VALERTER_TELEGRAM_TEST_MISSING_VAR}".to_string();
cfg.bot_token = SecretString::new("${VALERTER_TELEGRAM_TEST_MISSING_VAR}".to_string());
let err = TelegramNotifier::from_config("tg", &cfg, client).unwrap_err();
assert!(matches!(err, ConfigError::InvalidNotifier { .. }));
assert!(err.to_string().contains("bot_token"));
Expand Down Expand Up @@ -644,7 +645,7 @@ mod tests {
fn debug_impl_does_not_leak_token_or_endpoint() {
let client = reqwest::Client::new();
let mut cfg = config_with(vec!["-100".to_string()]);
cfg.bot_token = "SUPER_SECRET_TOKEN".to_string();
cfg.bot_token = SecretString::new("SUPER_SECRET_TOKEN".to_string());
let notifier = TelegramNotifier::from_config("tg", &cfg, client).unwrap();
let dbg = format!("{:?}", notifier);
assert!(dbg.contains("TelegramNotifier"));
Expand Down Expand Up @@ -756,7 +757,7 @@ mod tests {
fn from_config_rejects_empty_resolved_bot_token() {
let client = reqwest::Client::new();
let mut cfg = config_with(vec!["-100".to_string()]);
cfg.bot_token = " ".to_string();
cfg.bot_token = SecretString::new(" ".to_string());
let err = TelegramNotifier::from_config("tg", &cfg, client).unwrap_err();
assert!(matches!(err, ConfigError::InvalidNotifier { .. }));
assert!(err.to_string().contains("bot_token"));
Expand Down
Loading
Loading