feat: warn at worker startup when WHITELIST_ENVS forwards a secret-shaped variable (#11838)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-17 09:29:49 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 533670f577
commit 16c8be2355
2 changed files with 140 additions and 0 deletions
+2
View File
@@ -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!(
+138
View File
@@ -2808,6 +2808,93 @@ pub fn load_whitelist_env_vars_from_env() -> std::vec::IntoIter<String> {
.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::<char, usize>::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<Item = String>,
env_vars_static: &HashMap<String, String>,
@@ -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() {