1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
use async_trait::async_trait;
use super::{
emit_secret_access_event, RotationHandle, SecretBytes, SecretError, SecretId, SecretMeta,
SecretProvider, SecretVersion,
};
#[derive(Debug, Clone)]
pub struct EnvSecretProvider {
namespace: String,
}
impl EnvSecretProvider {
pub fn new(namespace: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
}
}
pub fn env_var_name(&self, id: &SecretId) -> String {
let namespace = normalize_env_component(&id.namespace);
let name = normalize_env_component(&id.name);
match id.version {
SecretVersion::Latest => format!("HARN_SECRET_{namespace}_{name}"),
SecretVersion::Exact(version) => format!("HARN_SECRET_{namespace}_{name}_V{version}"),
}
}
}
#[async_trait]
impl SecretProvider for EnvSecretProvider {
async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
let env_name = self.env_var_name(id);
match std::env::var(&env_name) {
Ok(value) if !value.is_empty() => {
emit_secret_access_event("env", id);
Ok(SecretBytes::from(value))
}
_ => Err(SecretError::NotFound {
provider: "env".to_string(),
id: id.clone(),
}),
}
}
/// Mutate the process environment so the secret is visible to
/// callers (and unfortunately to every child process spawned after
/// this point).
///
/// # Safety / Soundness
///
/// `std::env::set_var` is `unsafe` in Rust 2024 because the
/// underlying POSIX `setenv(3)` is not synchronized with concurrent
/// `getenv(3)` calls; another thread reading the environment at
/// the same moment can observe a half-updated or dangling pointer.
/// The harn host process drives a tokio runtime, so this hazard is
/// real even though no single call site currently triggers it. We
/// mark this `unsafe` block to flag the existing risk; the proper
/// fix is tracked as a follow-up — replace this with an in-process
/// `Mutex<HashMap<SecretId, SecretBytes>>` consulted by `get`, and
/// inject the secret narrowly via `Command::env(key, val)` at
/// spawn time so it does not leak into unrelated children.
///
/// # Child-process leakage
///
/// Beyond the soundness issue: once written here, the secret is
/// inherited by every subsequent `shell(...)`, `exec(...)`,
/// `tokio::process::Command::spawn`, and any tool we hand a child
/// process to. A `bash -c env` or a debugger attaching to a child
/// would see it. Treat this provider as a last-resort path; prefer
/// the keyring-backed provider when available.
async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
let env_name = self.env_var_name(id);
let rendered = value.with_exposed(|bytes| {
std::str::from_utf8(bytes)
.map(|text| text.to_string())
.map_err(|error| SecretError::Backend {
provider: "env".to_string(),
message: format!("env secrets must be valid UTF-8: {error}"),
})
})?;
// SAFETY: see the doc comment above. We accept the
// `setenv` data-race window as a known gap for the env-backed
// provider; the in-process Mutex<HashMap> replacement is
// tracked as a follow-up issue.
unsafe {
std::env::set_var(&env_name, rendered);
}
Ok(())
}
async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
Err(SecretError::Unsupported {
provider: "env".to_string(),
operation: "rotate",
})
}
async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
let env_prefix = if prefix.name.is_empty() {
format!(
"HARN_SECRET_{}_",
normalize_env_component(&prefix.namespace)
)
} else {
self.env_var_name(prefix)
};
let items = std::env::vars()
.filter_map(|(name, _)| {
if !name.starts_with(&env_prefix) {
return None;
}
let suffix = name
.strip_prefix(&format!(
"HARN_SECRET_{}_",
normalize_env_component(&prefix.namespace)
))
.unwrap_or_default()
.trim_start_matches('_')
.to_ascii_lowercase();
Some(SecretMeta {
id: SecretId::new(prefix.namespace.clone(), suffix),
provider: "env".to_string(),
})
})
.collect::<Vec<_>>();
Ok(items)
}
fn namespace(&self) -> &str {
&self.namespace
}
fn supports_versions(&self) -> bool {
false
}
}
fn normalize_env_component(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut last_was_underscore = false;
for ch in value.chars() {
let mapped = if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
};
if mapped == '_' {
if !last_was_underscore {
normalized.push(mapped);
}
last_was_underscore = true;
} else {
normalized.push(mapped);
last_was_underscore = false;
}
}
normalized.trim_matches('_').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_provider_uses_expected_variable_name() {
let provider = EnvSecretProvider::new("harn/test");
let id = SecretId::new("harn.orchestrator.github", "installation-12345/private-key");
assert_eq!(
provider.env_var_name(&id),
"HARN_SECRET_HARN_ORCHESTRATOR_GITHUB_INSTALLATION_12345_PRIVATE_KEY"
);
}
}