Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 29 additions & 3 deletions crates/mise-util/src/env.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Environment variables mise reads, and the directories derived from them.

use crate::env_diff::{EnvDiff, EnvMap};
use crate::env_diff::{EnvDiff, EnvMap, env_value_matches};
use crate::env_value::EnvValue;
use crate::file::replace_path;
use indexmap::IndexMap;
Expand Down Expand Up @@ -931,7 +931,9 @@ fn get_pristine_env(mise_diff: &EnvDiff, orig_env: EnvMap) -> EnvMap {
fn reverse_diff_preserving_overrides(mise_diff: &EnvDiff, mut env: EnvMap) -> EnvMap {
for (key, old_value) in &mise_diff.old {
match env_diff_get(&mise_diff.new, key) {
Some(new_value) if env_map_get(&env, key) == Some(new_value) => {
Some(new_value)
if env_map_get(&env, key).is_some_and(|v| env_value_matches(new_value, v)) =>
{
let key = env_map_key(&env, key)
.cloned()
.unwrap_or_else(|| key.clone());
Expand All @@ -946,7 +948,7 @@ fn reverse_diff_preserving_overrides(mise_diff: &EnvDiff, mut env: EnvMap) -> En

for (key, new_value) in &mise_diff.new {
if env_diff_get(&mise_diff.old, key).is_none()
&& env_map_get(&env, key) == Some(new_value)
&& env_map_get(&env, key).is_some_and(|v| env_value_matches(new_value, v))
&& let Some(key) = env_map_key(&env, key).cloned()
{
env.remove(&key);
Expand Down Expand Up @@ -1452,6 +1454,30 @@ mod tests {
.into()
);
}
#[test]
fn test_reverse_diff_with_hashed_new_values() {
let diff = EnvDiff {
old: [("CHANGED".into(), "before".into())].into(),
new: [
("ADDED".into(), crate::env_diff::hash_env_value("managed")),
("CHANGED".into(), crate::env_diff::hash_env_value("managed")),
]
.into(),
..Default::default()
};
let current = [
("ADDED".into(), "managed".into()),
("CHANGED".into(), "override".into()),
]
.into();
// CHANGED was overridden after mise applied it, so it is left alone;
// ADDED is still the managed value and is removed.
assert_eq!(
reverse_diff_preserving_overrides(&diff, current),
[("CHANGED".into(), "override".into())].into()
);
}

#[test]
fn test_reverse_diff_restores_unchanged_managed_values() {
let diff = EnvDiff {
Expand Down
66 changes: 65 additions & 1 deletion crates/mise-util/src/env_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ pub struct EnvDiff {
pub path: Vec<PathBuf>,
}

const HASH_PREFIX: &str = "blake3:";

/// Digest stored in place of an env value in `__MISE_DIFF` and `__MISE_SESSION`.
/// This is not a secret store: a low-entropy value can be brute-forced from its
/// digest, and the value itself is in every child's environment anyway. The
/// point is not to keep a second plaintext copy around.
pub fn hash_env_value(value: &str) -> String {
format!("{HASH_PREFIX}{}", blake3::hash(value.as_bytes()).to_hex())
}

/// Whether `actual` is the value recorded as `stored`. Blobs written by older
/// mise versions hold plaintext, newer ones a [`hash_env_value`] digest.
pub fn env_value_matches(stored: &str, actual: &str) -> bool {
stored == actual || stored == hash_env_value(actual)
}

#[derive(Debug)]
pub enum EnvDiffOperation {
Add(String, String),
Expand Down Expand Up @@ -197,9 +213,29 @@ impl EnvDiff {
Ok(rmp_serde::from_slice(&writer[..])?)
}

/// Serializes for `__MISE_DIFF`. The `new` values are replaced by
/// [`hash_env_value`] digests: they are only ever compared against the live
/// environment (see [`env_value_matches`]), so secrets mise injected are
/// not duplicated as plaintext in a variable every child process inherits.
/// `old` stays as-is because restoring the environment needs those values.
pub fn serialize(&self) -> Result<String> {
#[derive(Serialize)]
struct Wire<'a> {
old: &'a IndexMap<String, String>,
new: IndexMap<&'a str, String>,
path: &'a [PathBuf],
}
let wire = Wire {
old: &self.old,
new: self
.new
.iter()
.map(|(k, v)| (k.as_str(), hash_env_value(v)))
.collect(),
path: &self.path,
};
let mut gz = ZlibEncoder::new(Vec::new(), Compression::fast());
gz.write_all(&rmp_serde::to_vec_named(self)?)?;
gz.write_all(&rmp_serde::to_vec_named(&wire)?)?;
Ok(BASE64_STANDARD_NO_PAD.encode(gz.finish()?))
}

Expand Down Expand Up @@ -496,4 +532,32 @@ mod tests {
assert_eq!(parsed.get("AFTER").map(String::as_str), Some("\"after\""));
assert!(!parsed.keys().any(|k| k.starts_with("BASH_FUNC_")));
}

#[test]
fn test_serialize_does_not_store_new_values() {
let original: EnvMap = [("CHANGED".into(), "before".into())].into();
let diff = EnvDiff::new(
&original,
[
("CHANGED".to_string(), "after-secret".to_string()),
("ADDED".to_string(), "added-secret".to_string()),
],
);
let raw = diff.serialize().unwrap();
let back = EnvDiff::deserialize(&raw).unwrap();

assert_eq!(back.old.get("CHANGED").map(String::as_str), Some("before"));
for value in back.new.values() {
assert!(!value.contains("secret"), "{value}");
}
assert!(env_value_matches(&back.new["CHANGED"], "after-secret"));
assert!(!env_value_matches(&back.new["CHANGED"], "other"));
assert!(env_value_matches(&back.new["ADDED"], "added-secret"));
}

#[test]
fn test_env_value_matches_plaintext_from_older_mise() {
assert!(env_value_matches("plain", "plain"));
assert!(!env_value_matches("plain", "other"));
}
}
5 changes: 5 additions & 0 deletions docs/cache-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ a cached environment. Clear or disable the cache if those edits are not reflecte
Changes in an external service, such as a
rotated secret, are not file changes: choose a suitable TTL or disable environment caching.

mise does not cache secrets. A configuration with an `age`-encrypted value, a sops-encrypted
`_.file`, any directive with `redact = true`, or an env plugin that returns `redact = true` is
not environment-cached at all; mise recomputes its environment every time instead of writing the
decrypted values to disk.

For a command that must recompute environment values, set `MISE_ENV_CACHE=0` before starting
mise. For example, in a Node.js project with a `test` script:

Expand Down
1 change: 1 addition & 0 deletions docs/env-plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ separate policy if your plugin also accepts absolute paths.
| `redact` | Request redaction of returned values in mise's processed output; defaults to `false` |

A user's explicit directive-level `redact` option overrides the plugin's preference.
A module whose values are redacted is never environment-cached, whatever `cacheable` says.
Redaction does not remove values from the environment and raw task output bypasses it.
See [redactions](/environments/#redactions).

Expand Down
80 changes: 80 additions & 0 deletions e2e/env/test_env_cache_plugin_uncacheable
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash

# An env module that is not cacheable must keep the whole environment out of the env cache,
# whether it says so itself (cacheable = false) or the user marks its values redact = true.

export MISE_ENV_CACHE=1
export __MISE_ENV_CACHE_KEY="dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q="

COUNTER_FILE="$MISE_DATA_DIR/env_cache_test_counter"

make_plugin() {
local name="$1" cacheable="$2"
local dir="$MISE_DATA_DIR/plugins/$name"
mkdir -p "$dir/hooks"
cat >"$dir/metadata.lua" <<EOFMETA
PLUGIN = {}
PLUGIN.name = "$name"
PLUGIN.version = "1.0.0"
PLUGIN.homepage = "https://example.com"
PLUGIN.license = "MIT"
PLUGIN.description = "Test plugin"
PLUGIN.minRuntimeVersion = "0.3.0"
EOFMETA
cat >"$dir/hooks/mise_env.lua" <<EOFHOOK
function PLUGIN:MiseEnv(ctx)
local f = io.open("$COUNTER_FILE", "r")
local count = tonumber(f:read("*all")) or 0
f:close()
f = io.open("$COUNTER_FILE", "w")
f:write(tostring(count + 1))
f:close()
return {
env = {{key = "UNCACHEABLE_TEST_VAR", value = "value"}},
cacheable = $cacheable,
watch_files = {}
}
end
EOFHOOK
}

cached_files() {
find "$MISE_STATE_DIR/env-cache" -type f 2>/dev/null | wc -l | tr -d ' '
}

run_three_times() {
rm -rf "$MISE_STATE_DIR/env-cache"
echo 0 >"$COUNTER_FILE"
mise env -s bash >/dev/null 2>&1
mise env -s bash >/dev/null 2>&1
mise env -s bash >/dev/null 2>&1
cat "$COUNTER_FILE"
}

make_plugin test-uncacheable-env false
cat >"$MISE_CONFIG_DIR/config.toml" <<EOF
[env]
_.test-uncacheable-env = {}
EOF
count="$(run_three_times)"
assert "echo $count" "3"
assert "echo 0" "$(cached_files)"

# the plugin says cacheable = true, but the user redacts its values: still not cached
make_plugin test-redacted-env true
cat >"$MISE_CONFIG_DIR/config.toml" <<EOF
[env]
_.test-redacted-env = { redact = true }
EOF
count="$(run_three_times)"
assert "echo $count" "3"
assert "echo 0" "$(cached_files)"

# control: without redact the same plugin is cached
cat >"$MISE_CONFIG_DIR/config.toml" <<EOF
[env]
_.test-redacted-env = {}
EOF
count="$(run_three_times)"
assert "echo $count" "1"
[ "$(cached_files)" -gt 0 ] || fail "expected the cacheable environment to be cached"
51 changes: 51 additions & 0 deletions e2e/env/test_env_secret_hygiene
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash

# Secret values must not be copied into shell state, and must not reach the env cache.

export MISE_ENV_CACHE=1
export __MISE_ENV_CACHE_KEY="dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q="

cat >"$MISE_CONFIG_DIR/config.toml" <<TOML
[env]
PLAIN_VAR = "plain-value-marker"
API_TOKEN = { value = "s3cr3t-marker", redact = true }
TOML

# prints the decompressed msgpack of a __MISE_DIFF / __MISE_SESSION blob
decode_state() {
python3 -c '
import base64, sys, zlib
raw = sys.argv[1]
sys.stdout.buffer.write(zlib.decompress(base64.b64decode(raw + "=" * (-len(raw) % 4))))
' "$1" | tr -c '[:print:]' ' '
}

out="$(mise hook-env -s bash --force)"
diff_blob="$(sed -n "s/^export __MISE_DIFF=//p" <<<"$out")"
session_blob="$(sed -n "s/^export __MISE_SESSION=//p" <<<"$out")"
[ -n "$diff_blob" ] || fail "no __MISE_DIFF in hook-env output: $out"
[ -n "$session_blob" ] || fail "no __MISE_SESSION in hook-env output: $out"

# keys are tracked, values are not
export DECODED_DIFF DECODED_SESSION
DECODED_DIFF="$(decode_state "$diff_blob")"
DECODED_SESSION="$(decode_state "$session_blob")"
for var in DECODED_DIFF DECODED_SESSION; do
assert_contains "echo \"\$$var\"" "API_TOKEN"
assert_not_contains "echo \"\$$var\"" "s3cr3t-marker"
assert_not_contains "echo \"\$$var\"" "plain-value-marker"
done

# the shell still gets the real value
assert_contains "echo \"$out\"" "s3cr3t-marker"

# a redacted value is never written to the env cache
assert "find '$MISE_STATE_DIR/env-cache' -type f 2>/dev/null | wc -l | tr -d ' '" "0"

# without a secret, the same config is cached as before
cat >"$MISE_CONFIG_DIR/config.toml" <<TOML
[env]
PLAIN_VAR = "plain-value-marker"
TOML
mise env -s bash >/dev/null
assert_directory_exists "$MISE_STATE_DIR/env-cache"
4 changes: 4 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,10 @@ cache when those changes are not reflected.
Env plugins can return `cacheable = true` and `watch_files` from their `MiseEnv` hook.
See [Env Plugin Development](/env-plugin-development.html) for the Lua return format.

mise does not cache secrets: a configuration with an `age` value, a sops-encrypted `_.file`, or
a directive with `redact = true` (including an env plugin that returns `redact = true`) is never
written to the cache, and neither is any environment from a plugin that returns `cacheable = false`.

Ordinary environment directives do not currently support a per-value `cacheable = false`
option. Set `MISE_ENV_CACHE=0` before starting mise when a command must recompute values.
Changes in an external service do not invalidate the cache by themselves; choose a suitable
Expand Down
25 changes: 15 additions & 10 deletions src/cli/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::config::{Config, Settings};
use crate::direnv::DirenvDiff;
use crate::env::{__MISE_DIFF, PATH_KEY, TERM_WIDTH};
use crate::env::{join_paths, split_paths};
use crate::env_diff::{EnvDiff, EnvDiffOperation, EnvMap};
use crate::env_diff::{EnvDiff, EnvDiffOperation, EnvMap, env_value_matches};
use crate::file::{self, canonicalize_cached, display_path, display_rel_path};
use crate::hook_env::{PREV_SESSION, WatchFilePattern};
use crate::shell::{EXAMPLE_SHELL, ShellType, require_shell};
Expand Down Expand Up @@ -289,16 +289,21 @@ impl HookEnv {
}
}
if self.status || Settings::get().status.show_env {
let mut env_diff = EnvDiff::new(&PREV_SESSION.env, cur_env.clone()).to_patches();
// TODO: this logic should be in EnvDiff
let removed_keys = PREV_SESSION
.env
.keys()
.collect::<IndexSet<_>>()
.difference(&cur_env.keys().collect::<IndexSet<_>>())
.map(|k| EnvDiffOperation::Remove(k.to_string()))
let mut env_diff = cur_env
.iter()
.filter_map(|(k, v)| match PREV_SESSION.env.get(k) {
Some(prev) if env_value_matches(prev, v) => None,
Some(_) => Some(EnvDiffOperation::Change(k.clone(), String::new())),
None => Some(EnvDiffOperation::Add(k.clone(), String::new())),
})
.collect_vec();
env_diff.extend(removed_keys);
env_diff.extend(
PREV_SESSION
.env
.keys()
.filter(|k| !cur_env.contains_key(*k))
.map(|k| EnvDiffOperation::Remove(k.clone())),
);
if !env_diff.is_empty() {
let env_diff = env_diff.into_iter().map(patch_to_status).join(" ");
info!("{}", format_status(&env_diff));
Expand Down
33 changes: 33 additions & 0 deletions src/config/config_file/mise_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2579,6 +2579,13 @@ impl<'de> de::Deserialize<'de> for EnvList {
opts.tools =
tools.as_bool().unwrap_or(false);
}
// The user's explicit `redact` overrides the plugin's own preference.
if let Some(table) = value.as_table_mut()
&& let Some(redact) =
table.remove("redact")
{
opts.redact = redact.as_bool();
Comment thread
jdx marked this conversation as resolved.
Resolved
}
directives.push(EnvDirective::Module(
key, value, opts,
));
Expand Down Expand Up @@ -4931,6 +4938,32 @@ run = "cargo build"
"#);
}

#[test]
fn test_env_module_redact_option() {
let toml = indoc! {r#"
[env]
_.secret-plugin = { redact = false, vault = "main" }
_.other-plugin = { vault = "main" }
"#}
.to_string();
let entries = parse(toml).env_entries().unwrap();
let module = |name: &str| {
entries
.iter()
.find_map(|d| match d {
EnvDirective::Module(n, value, opts) if n == name => Some((value, opts)),
_ => None,
})
.unwrap()
};
let (value, opts) = module("secret-plugin");
assert_eq!(opts.redact, Some(false));
// `redact` is a mise directive control, not an option for the plugin
assert!(value.get("redact").is_none());
assert_eq!(value.get("vault").and_then(|v| v.as_str()), Some("main"));
assert_eq!(module("other-plugin").1.redact, None);
}

#[test]
fn test_env_default_invalid_combinations() {
let err = parse_error("[env]\nFOO = { value = \"x\", default = \"y\" }\n");
Expand Down
Loading
Loading