From 16c8be2355b919878091769810749c54c2f0a09d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 17 Oct 2026 09:29:49 +0200 Subject: [PATCH] feat: warn at worker startup when WHITELIST_ENVS forwards a secret-shaped variable (#11838) Co-authored-by: Claude Opus 5.5 --- backend/src/main.rs | 2 + backend/windmill-common/src/worker.rs | 138 ++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/backend/src/main.rs b/backend/src/main.rs index a577978d48..6ffeb51ace 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -849,6 +849,8 @@ async fn windmill_main() -> anyhow::Result<()> { let worker_mode = num_workers > 0; if worker_mode { + windmill_common::worker::warn_secret_shaped_whitelist_envs(); + #[cfg(any(target_os = "linux"))] if let Err(e) = disable_oom_group() { tracing::warn!( diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index a28204d202..868f7fe29e 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -2808,6 +2808,93 @@ pub fn load_whitelist_env_vars_from_env() -> std::vec::IntoIter { .into_iter() } +/// Warns, by name only, about each `WHITELIST_ENVS` entry that looks like a credential: +/// every job on the worker can read what is listed there. +pub fn warn_secret_shaped_whitelist_envs() { + for name in load_whitelist_env_vars_from_env().sorted().unique() { + let value = std::env::var(&name).unwrap_or_default(); + if looks_like_secret(&name, &value) { + tracing::warn!( + "WHITELIST_ENVS forwards {name}, which looks like a secret, to every job on \ + this worker, where any script can read it. Store it as a secret variable or \ + resource, or in a secret backend, and remove it from WHITELIST_ENVS" + ); + } + } +} + +fn looks_like_secret(name: &str, value: &str) -> bool { + const SECRET_WORDS: &[&str] = &[ + "TOKEN", + "TOKENS", + "SECRET", + "SECRETS", + "PASSWORD", + "PASSWD", + "KEY", + "APIKEY", + "CREDENTIAL", + "CREDENTIALS", + "AUTH", + "PRIVATE", + ]; + let name_matches = name + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|word| SECRET_WORDS.contains(&word.to_ascii_uppercase().as_str())); + if name_matches { + return true; + } + + let value = value.trim(); + let is_private_pem = value.contains("-----BEGIN ") && value.contains("PRIVATE KEY-----"); + let is_jwt = value.starts_with("eyJ") && { + let parts: Vec<&str> = value.split('.').collect(); + parts.len() == 3 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=') + }) + }; + let has_url_password = url::Url::parse(value).is_ok_and(|u| u.password().is_some()); + is_private_pem || is_jwt || has_url_password || is_high_entropy(value) +} + +/// A single token whose longest run between separators is 24+ chars mixing letters and digits +/// with more than 4 bits of entropy per char: above what hex digests, UUIDs and words reach. +/// Scoring the whole value instead flags pod names and cloud resource paths, whose entropy +/// grows with their length. +fn is_high_entropy(value: &str) -> bool { + if !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+/=_-".contains(c)) + { + return false; + } + let Some(run) = value.split(['-', '_', '/']).max_by_key(|s| s.len()) else { + return false; + }; + if run.len() < 24 + || !run.chars().any(|c| c.is_ascii_digit()) + || !run.chars().any(|c| c.is_ascii_alphabetic()) + { + return false; + } + let mut counts = HashMap::::new(); + for c in run.chars() { + *counts.entry(c).or_default() += 1; + } + let len = run.len() as f64; + let entropy: f64 = counts + .values() + .map(|&n| { + let p = n as f64 / len; + -p * p.log2() + }) + .sum(); + entropy > 4.0 +} + pub fn load_env_vars( iter: impl Iterator, env_vars_static: &HashMap, @@ -3106,6 +3193,57 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn whitelist_env_secret_heuristic() { + let cases = [ + ("GITHUB_TOKEN", "x", true), + ("aws_secret_access_key", "", true), + ("DB-PASSWORD", "x", true), + ("OPENAI_APIKEY", "x", true), + ("MY_CREDENTIALS", "x", true), + ("PRIVATE_REGISTRY", "x", true), + ("MONKEY", "banana", false), + ("KEYCLOAK_URL", "https://kc.example.com", false), + ("TOKENIZER_PATH", "/opt/tok", false), + ( + "SSH_CFG", + "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----", + true, + ), + ( + "CA_CERT", + "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----", + false, + ), + ( + "JWT_LIKE", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl", + true, + ), + ("DB_URL", "postgres://user:hunter2@db:5432/app", true), + ("PROXY", "http://proxy.local:3128", false), + ("PROXY_USER", "http://bob@proxy.local:3128", false), + ("OPAQUE", "app_51HxQ2vLk9Tq8ZpR3mNcY7wB4aD6eF0gJ", true), + ("GIT_SHA", "3f786850e387550fdab836ed7e6dc881de23001b", false), + ("RUN_ID", "123e4567-e89b-12d3-a456-426614174000", false), + ( + "SOME_PATH", + "/usr/local/lib/python3.11/site-packages/x1", + false, + ), + ("REGION", "eu-west-1", false), + ("HOSTNAME", "windmill-workers-7d9f8c6b5-x2k4p", false), + ( + "TOPIC", + "projects/my-gcp-project-123/locations/europe-west1", + false, + ), + ]; + for (name, value, expected) in cases { + assert_eq!(looks_like_secret(name, value), expected, "{name}={value}"); + } + } + #[cfg(not(windows))] #[test] fn cgroup_cpu_stat_is_normalized_to_microseconds() {