mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 08:03:28 +00:00
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:
co-authored by
Claude Opus 5.5
parent
533670f577
commit
16c8be2355
@@ -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!(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user