use std::{
io::{BufRead, BufReader, Read, Write},
process::{Command, Stdio},
thread,
};
use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_cli::cli::ExecModeSetting;
use tsafe_core::{
audit::{AuditContext, AuditEntry, AuditEnvMapping, AuditExecContext},
contracts::{
find_contracts_manifest, load_contract, AuthorityContract,
AuthorityInheritMode as ContractInheritMode, AuthorityTrustLevel, ResolvedAuthorityPolicy,
},
deny_reason::DenyReason,
env as tsenv,
profile::{self, ExecCustomInheritMode, ExecMode},
rbac::RbacProfile,
};
use crate::helpers::*;
struct ParsedEnvMapping {
env_var: String,
vault_key: String,
}
#[derive(Clone, Debug)]
enum EffectiveInheritMode {
Full,
Minimal,
Clean,
Only(Vec<String>),
}
#[derive(Clone, Debug)]
struct ResolvedExecTrustMode {
mode: ExecMode,
source: ExecTrustSource,
inherit: EffectiveInheritMode,
deny_dangerous_env: bool,
redact_output: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExecTrustSource {
Explicit,
Contract,
Config,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_exec(
profile: &str,
profile_explicit: bool,
contract_name: Option<&str>,
cmd_parts: Vec<String>,
ns: Option<&str>,
keys: Vec<String>,
mode: Option<ExecModeSetting>,
dry_run: bool,
plan: bool,
no_inherit: bool,
minimal: bool,
only: Vec<String>,
require: Vec<String>,
env_mappings_raw: Vec<String>,
deny_dangerous_env: bool,
allow_dangerous_env: bool,
redact_output: bool,
no_redact_output: bool,
) -> Result<()> {
if !dry_run && !plan && cmd_parts.is_empty() {
anyhow::bail!("no command given. Usage: tsafe exec -- <cmd> [args...]");
}
struct LoadedContract {
contract: AuthorityContract,
}
let contract: Option<LoadedContract> = if let Some(name) = contract_name {
let cwd = std::env::current_dir()
.context("failed to read current directory for contract manifest search")?;
let manifest_path = find_contracts_manifest(&cwd).ok_or_else(|| {
anyhow::anyhow!(
"--contract '{name}': no .tsafe.yml or .tsafe.json manifest found \
(searched upward from {})\n\
\n Create a manifest in your project root:\
\n echo 'contracts:\\n {name}:\\n profile: default' > .tsafe.yml\
\n Or see: tsafe explain contracts",
cwd.display()
)
})?;
let contract = load_contract(&manifest_path, name).with_context(|| {
format!(
"--contract '{name}': failed to load from {}",
manifest_path.display()
)
})?;
Some(LoadedContract { contract })
} else {
None
};
if let Some(loaded) = contract.as_ref() {
if profile_explicit {
if let Some(contract_profile) = loaded.contract.profile.as_deref() {
if profile != contract_profile {
anyhow::bail!(
"--contract '{}': explicit profile '{}' conflicts with bound contract profile '{}'\n\
\n This contract is bound to one profile and cannot be widened with --profile.\
\n Re-run without --profile, or update the contract's profile in .tsafe.yml.",
loaded.contract.name,
profile,
contract_profile
);
}
}
}
if let (Some(explicit_ns), Some(contract_ns)) = (ns, loaded.contract.namespace.as_deref()) {
if explicit_ns != contract_ns {
anyhow::bail!(
"--contract '{}': explicit namespace '{}' conflicts with bound contract namespace '{}'\n\
\n This contract is bound to one namespace and cannot be widened with --ns.\
\n Re-run without --ns, or update the contract's namespace in .tsafe.yml.",
loaded.contract.name,
explicit_ns,
contract_ns
);
}
}
}
let effective_profile: String = if profile_explicit {
profile.to_string()
} else if let Some(ref c) = contract {
c.contract
.profile
.clone()
.unwrap_or_else(|| profile.to_string())
} else {
profile.to_string()
};
let effective_ns: Option<&str> = ns.or_else(|| {
contract
.as_ref()
.and_then(|loaded| loaded.contract.namespace.as_deref())
});
let explicit_selected_names = normalize_requested_names(keys);
let explicit_required_names = normalize_requested_names(require);
let contract_allowed_names = contract
.as_ref()
.map(|loaded| loaded.contract.allowed_secrets.clone())
.unwrap_or_default();
let contract_required_names = contract
.as_ref()
.map(|loaded| loaded.contract.required_secrets.clone())
.unwrap_or_default();
let (selected_names, blocked_selected_names) =
resolve_selected_names(&explicit_selected_names, &contract_allowed_names);
let (required_names, blocked_required_names) = resolve_required_names(
&explicit_required_names,
&contract_required_names,
&contract_allowed_names,
);
let contract_policy: Option<ResolvedAuthorityPolicy> = contract
.as_ref()
.map(|loaded| loaded.contract.resolved_exec_policy());
let resolved_mode = resolve_exec_trust_mode(ExecTrustInputs {
explicit_mode: mode,
contract_policy,
no_inherit,
minimal,
only: &only,
deny_dangerous_env,
allow_dangerous_env,
redact_output,
no_redact_output,
});
let contract_ref = contract.as_ref().map(|loaded| &loaded.contract);
if let Some(authority) = contract_ref {
if let Some(command) = cmd_parts.first() {
let evaluation = authority.evaluate_target(Some(command));
if !evaluation.decision.is_allowed() {
let allowed = authority.allowed_targets.join(", ");
let message = format!(
"--contract '{}': command '{}' is not in allowed_targets [{}]\n\
\n To allow it, add '{}' to allowed_targets in .tsafe.yml under contract '{}'.\
\n To run without target restriction, omit --contract.",
authority.name, command, allowed, command, authority.name
);
append_exec_failure_audit(
&effective_profile,
effective_ns,
contract_ref,
&cmd_parts,
&resolved_mode,
&required_names,
&[],
DenyReason::TargetNotAllowed,
&message,
);
anyhow::bail!(message);
}
}
}
let vault_access_profile = contract_policy
.map(|policy| policy.access_profile)
.unwrap_or(RbacProfile::ReadWrite);
let vault = open_vault_with_access_profile(&effective_profile, vault_access_profile)?;
for (key, entry) in &vault.file().secrets {
warn_if_expired(key, &entry.tags);
}
let ns_prefix = effective_ns.map(|n| format!("{n}/"));
let mut secrets = vault.export_all().context("failed to decrypt secrets")?;
secrets.retain(|_, v| !v.starts_with("@alias:"));
if let Some(ref pfx) = ns_prefix {
secrets.retain(|k, _| k.starts_with(pfx.as_str()));
secrets = secrets
.into_iter()
.map(|(k, v)| (k.strip_prefix(pfx.as_str()).unwrap_or(&k).to_string(), v))
.collect();
}
let mut missing_selected_names: Vec<String> = Vec::new();
if !selected_names.is_empty() {
let selected_set: std::collections::HashSet<&str> =
selected_names.iter().map(String::as_str).collect();
if !explicit_selected_names.is_empty() {
for name in &selected_names {
if !secrets.contains_key(name) {
missing_selected_names.push(name.clone());
}
}
}
secrets.retain(|key, _| selected_set.contains(key.as_str()));
}
let mut parsed_env_mappings: Vec<ParsedEnvMapping> = Vec::new();
for raw in &env_mappings_raw {
let Some((env_var, vault_key)) = raw.split_once('=') else {
anyhow::bail!(
"tsafe exec: --env {raw}: invalid format — expected ENV_VAR=VAULT_KEY\n\
\n Example: --env MY_DB=PROD_SECRET"
);
};
let env_var = env_var.trim().to_string();
let vault_key = vault_key.trim().to_string();
if env_var.is_empty() || vault_key.is_empty() {
anyhow::bail!(
"tsafe exec: --env {raw}: ENV_VAR and VAULT_KEY must both be non-empty\n\
\n Example: --env MY_DB=PROD_SECRET"
);
}
if !explicit_selected_names.is_empty() {
let keys_set: std::collections::HashSet<&str> =
explicit_selected_names.iter().map(String::as_str).collect();
if !keys_set.contains(vault_key.as_str()) {
anyhow::bail!(
"tsafe exec: --env {env_var}={vault_key}: {vault_key} is not in the --keys allowlist\n\
\n The --keys flag limits which vault keys can be injected. Either add \
'{vault_key}' to --keys or remove it from --env."
);
}
}
if !contract_allowed_names.is_empty() {
let contract_set: std::collections::HashSet<&str> =
contract_allowed_names.iter().map(String::as_str).collect();
if !contract_set.contains(vault_key.as_str()) {
let cname = contract_name.unwrap_or("<contract>");
anyhow::bail!(
"tsafe exec: --env {env_var}={vault_key}: {vault_key} is not in the \
allowed_secrets for contract '{cname}'\n\
\n To allow it, add '{vault_key}' to allowed_secrets in .tsafe.yml under contract '{cname}'.\
\n To inject without restriction, omit --contract."
);
}
}
if !secrets.contains_key(&vault_key) {
anyhow::bail!(
"tsafe exec: --env {env_var}={vault_key}: vault key '{vault_key}' not found\n\
\n See available keys: tsafe list\
\n Add it: tsafe set {vault_key} <value>"
);
}
parsed_env_mappings.push(ParsedEnvMapping { env_var, vault_key });
}
for mapping in &parsed_env_mappings {
if let Some(value) = secrets.get(&mapping.vault_key).cloned() {
secrets.insert(mapping.env_var.clone(), value);
}
}
if plan {
print_exec_plan(
&effective_profile,
effective_ns,
contract_name,
vault_access_profile,
profile_explicit,
&cmd_parts,
&secrets,
&required_names,
&explicit_selected_names,
&missing_selected_names,
&blocked_selected_names,
&blocked_required_names,
&resolved_mode,
mode,
ns,
no_inherit,
minimal,
&only,
);
return Ok(());
}
if !blocked_selected_names.is_empty() {
let cname = contract_name.unwrap_or("<contract>");
let keys = blocked_selected_names.join(", ");
anyhow::bail!(
"selected key(s) not allowed by contract '{cname}': {keys}\n\
\n To allow them, add the key(s) to allowed_secrets under contract '{cname}' in .tsafe.yml.\
\n To inject without restriction, omit --contract."
);
}
if !blocked_required_names.is_empty() {
let cname = contract_name.unwrap_or("<contract>");
let keys = blocked_required_names.join(", ");
anyhow::bail!(
"required key(s) not allowed by contract '{cname}': {keys}\n\
\n allowed_secrets in contract '{cname}' must include every required key.\
\n Add the key(s) to allowed_secrets in .tsafe.yml."
);
}
if !missing_selected_names.is_empty() {
let keys = missing_selected_names.join(", ");
let ns_ctx = effective_ns
.map(|n| format!(" (namespace '{n}')"))
.unwrap_or_default();
anyhow::bail!(
"selected key(s) not found{ns_ctx}: {keys}\n\
\n See available keys: tsafe list\
\n Add a missing key: tsafe set <key> <value>"
);
}
for name in &required_names {
if !secrets.contains_key(name) {
let message = format!(
"required secret '{name}' not found in vault\n\
\n Add it: tsafe set {name} <value>\
\n See all keys: tsafe list\
\n Preview what runs: tsafe exec --plan"
);
append_exec_failure_audit(
&effective_profile,
effective_ns,
contract_ref,
&cmd_parts,
&resolved_mode,
&required_names,
&[name.clone()],
DenyReason::RequiredSecretNotFound,
&message,
);
anyhow::bail!(message);
}
}
for name in secrets.keys() {
if tsenv::is_dangerous_injected_env_name(name) {
if resolved_mode.deny_dangerous_env {
let message = format!(
"DANGEROUS_ENV_VARIABLE: refusing to inject high-risk env var `{name}` — use --allow-dangerous-env to permit"
);
append_exec_failure_audit(
&effective_profile,
effective_ns,
contract_ref,
&cmd_parts,
&resolved_mode,
&required_names,
&[],
DenyReason::DangerousEnvVariable,
&message,
);
anyhow::bail!(message);
}
eprintln!(
"{} Vault key `{name}` maps to a high-risk env var when injected — verify this is intentional",
"!".yellow()
);
}
}
if dry_run {
let mut keys: Vec<&String> = secrets.keys().collect();
keys.sort();
for k in keys {
println!("{k}");
}
return Ok(());
}
let audit_entry = build_exec_audit_entry(
&effective_profile,
effective_ns,
contract_ref,
&cmd_parts,
&resolved_mode,
&secrets,
&required_names,
&parsed_env_mappings,
);
audit(&effective_profile).append(&audit_entry).ok();
drop(vault);
let use_clean_env = !matches!(resolved_mode.inherit, EffectiveInheritMode::Full);
let code = if use_clean_env {
let mut keep: std::collections::HashMap<String, String> = std::collections::HashMap::new();
match &resolved_mode.inherit {
EffectiveInheritMode::Minimal => {
for name in tsenv::MINIMAL_ENV_VARS {
if let Ok(val) = std::env::var(name) {
keep.insert((*name).to_string(), val);
}
}
}
EffectiveInheritMode::Only(names) => {
for name in names {
if let Ok(val) = std::env::var(name) {
keep.insert(name.clone(), val);
}
}
}
EffectiveInheritMode::Clean | EffectiveInheritMode::Full => {}
}
if resolved_mode.redact_output {
spawn_with_redacted_output(
tsenv::clean_env_command(&secrets, &keep, &cmd_parts)?,
&secrets,
)?
} else {
tsenv::exec_clean_env(&secrets, &keep, &cmd_parts)?
}
} else if resolved_mode.redact_output {
spawn_with_redacted_output(tsenv::command_with_secrets(&secrets, &cmd_parts)?, &secrets)?
} else {
tsenv::exec_with_secrets(&secrets, &cmd_parts)?
};
std::process::exit(code);
}
#[allow(clippy::too_many_arguments)]
fn print_exec_plan(
profile: &str,
ns: Option<&str>,
contract_name: Option<&str>,
access_profile: RbacProfile,
profile_explicit: bool,
cmd_parts: &[String],
secrets: &std::collections::HashMap<String, String>,
required_names: &[String],
requested_selected_names: &[String],
missing_selected_names: &[String],
blocked_selected_names: &[String],
blocked_required_names: &[String],
resolved_mode: &ResolvedExecTrustMode,
explicit_mode: Option<ExecModeSetting>,
explicit_ns: Option<&str>,
no_inherit: bool,
minimal: bool,
only: &[String],
) {
let sep = "─".repeat(52);
println!("{}", sep.dimmed());
println!("{}", " tsafe exec — plan".bold());
println!("{}", sep.dimmed());
let ns_display = ns.unwrap_or("(none)");
let cmd_display = if cmd_parts.is_empty() {
"(no command — add -- <cmd> after flags)".to_string()
} else {
cmd_parts.join(" ")
};
println!(" {:14} {}", "Profile:".dimmed(), profile.cyan());
println!(" {:14} {}", "Namespace:".dimmed(), ns_display.cyan());
if let Some(name) = contract_name {
println!(" {:14} {}", "Contract:".dimmed(), name.cyan());
}
println!(
" {:14} {}",
"Access:".dimmed(),
access_profile.as_str().cyan()
);
println!(" {:14} {}", "Command:".dimmed(), cmd_display.cyan());
let mode_display = match resolved_mode.source {
ExecTrustSource::Explicit => format!("{} (explicit)", resolved_mode.mode.as_str()),
ExecTrustSource::Contract => format!("{} (from contract)", resolved_mode.mode.as_str()),
ExecTrustSource::Config => format!("{} (from config)", resolved_mode.mode.as_str()),
};
let inherit_display = match &resolved_mode.inherit {
EffectiveInheritMode::Full => "full parent env (minus sensitive strips)".to_string(),
EffectiveInheritMode::Minimal => format!(
"minimal: PATH/HOME/USER/TMPDIR/LANG/TERM/SSH_AUTH_SOCK + {} more",
tsenv::MINIMAL_ENV_VARS.len().saturating_sub(7)
),
EffectiveInheritMode::Clean => "clean (--no-inherit): only vault secrets".to_string(),
EffectiveInheritMode::Only(names) => format!("--only: {}", names.join(", ")),
};
println!(" {:14} {}", "Mode:".dimmed(), mode_display.cyan());
println!(" {:14} {}", "Inherit:".dimmed(), inherit_display.cyan());
println!(
" {:14} {}",
"Output:".dimmed(),
if resolved_mode.redact_output {
"redacted ([REDACTED] exact-value replacement on stdout/stderr)".cyan()
} else {
"raw child stdout/stderr".cyan()
}
);
println!();
let mut sorted_keys: Vec<&String> = secrets.keys().collect();
sorted_keys.sort();
if sorted_keys.is_empty() {
println!(
" {} (vault is empty{})",
"Injected:".dimmed(),
if ns.is_some() {
" under this namespace"
} else {
""
}
);
} else {
println!(" {} ({}):", "Injected".dimmed(), sorted_keys.len());
for name in &sorted_keys {
if tsenv::is_dangerous_injected_env_name(name) {
println!(" {} {}", name.yellow(), "⚠ high-risk env var".yellow());
} else {
println!(" {name}");
}
}
}
if !requested_selected_names.is_empty() || !blocked_selected_names.is_empty() {
println!();
println!(" {}:", "--keys selection".dimmed());
for name in requested_selected_names {
if blocked_selected_names.contains(name) {
println!(" {} {}", name, "✗ blocked by contract".red());
} else if missing_selected_names.contains(name) {
println!(" {} {}", name, "✗ not in vault".red());
} else {
println!(" {} {}", name, "✓ selected".green());
}
}
}
if !required_names.is_empty() || !blocked_required_names.is_empty() {
println!();
println!(" {}:", "--require checks".dimmed());
for name in blocked_required_names {
println!(" {} {}", name, "✗ blocked by contract".red());
}
for name in required_names
.iter()
.filter(|name| !blocked_required_names.contains(*name))
{
if secrets.contains_key(name) {
println!(" {} {}", name, "✓ present".green());
} else {
println!(" {} {}", name, "✗ not in vault".red());
}
}
}
if matches!(resolved_mode.inherit, EffectiveInheritMode::Full) {
let strips: Vec<String> = tsenv::sensitive_parent_env_vars()
.into_iter()
.filter(|v| std::env::var(v).is_ok())
.collect();
if !strips.is_empty() {
println!();
println!(
" {} ({} found in parent env):",
"Strips".dimmed(),
strips.len()
);
for name in &strips {
println!(
" {} {}",
name.yellow(),
"← will be removed before exec".dimmed()
);
}
}
}
println!();
let run_line = build_run_line(RunLineSpec {
profile,
profile_explicit,
contract_name,
ns: explicit_ns,
cmd_parts,
selected_names: requested_selected_names,
explicit_mode,
no_inherit,
minimal,
only,
});
println!(" {}:", "Run".dimmed());
println!(" {}", run_line.bright_cyan());
println!();
println!("{}", sep.dimmed());
}
struct RunLineSpec<'a> {
profile: &'a str,
profile_explicit: bool,
contract_name: Option<&'a str>,
ns: Option<&'a str>,
cmd_parts: &'a [String],
selected_names: &'a [String],
explicit_mode: Option<ExecModeSetting>,
no_inherit: bool,
minimal: bool,
only: &'a [String],
}
fn build_run_line(spec: RunLineSpec<'_>) -> String {
let mut parts = vec!["tsafe".to_string()];
if spec.profile_explicit {
parts.push(format!("--profile {}", spec.profile));
}
parts.push("exec".to_string());
if let Some(name) = spec.contract_name {
parts.push(format!("--contract {name}"));
}
if let Some(n) = spec.ns {
parts.push(format!("--ns {n}"));
}
if !spec.selected_names.is_empty() {
parts.push(format!("--keys {}", spec.selected_names.join(",")));
}
if let Some(mode) = spec.explicit_mode {
parts.push(format!("--mode {}", exec_mode_flag_value(mode)));
}
if !spec.only.is_empty() {
parts.push(format!("--only {}", spec.only.join(",")));
} else if spec.no_inherit {
parts.push("--no-inherit".to_string());
} else if spec.minimal {
parts.push("--minimal".to_string());
}
if spec.cmd_parts.is_empty() {
parts.push("-- <cmd>".to_string());
} else {
parts.push("--".to_string());
parts.extend(spec.cmd_parts.iter().cloned());
}
parts.join(" ")
}
struct ExecTrustInputs<'a> {
explicit_mode: Option<ExecModeSetting>,
contract_policy: Option<ResolvedAuthorityPolicy>,
no_inherit: bool,
minimal: bool,
only: &'a [String],
deny_dangerous_env: bool,
allow_dangerous_env: bool,
redact_output: bool,
no_redact_output: bool,
}
fn resolve_exec_trust_mode(inputs: ExecTrustInputs<'_>) -> ResolvedExecTrustMode {
let (mode, source, base_inherit, base_deny, base_redact) =
if let Some(mode) = inputs.explicit_mode {
let mode = map_exec_mode_setting(mode);
let (inherit, deny, redact) = defaults_for_exec_mode(mode);
(mode, ExecTrustSource::Explicit, inherit, deny, redact)
} else if let Some(policy) = inputs.contract_policy {
(
map_contract_trust_level(policy.trust_level),
ExecTrustSource::Contract,
map_contract_inherit(policy.inherit),
policy.deny_dangerous_env,
policy.redact_output,
)
} else {
let mode = profile::get_exec_mode();
let (inherit, deny, redact) = defaults_for_exec_mode(mode);
(mode, ExecTrustSource::Config, inherit, deny, redact)
};
let inherit = if !inputs.only.is_empty() {
EffectiveInheritMode::Only(inputs.only.to_vec())
} else if inputs.no_inherit {
EffectiveInheritMode::Clean
} else if inputs.minimal {
EffectiveInheritMode::Minimal
} else {
base_inherit
};
let deny = if inputs.allow_dangerous_env {
false
} else {
base_deny || inputs.deny_dangerous_env
};
ResolvedExecTrustMode {
mode,
source,
inherit,
deny_dangerous_env: deny,
redact_output: if inputs.redact_output {
true
} else if inputs.no_redact_output {
false
} else {
base_redact
},
}
}
fn defaults_for_exec_mode(mode: ExecMode) -> (EffectiveInheritMode, bool, bool) {
match mode {
ExecMode::Standard => (EffectiveInheritMode::Full, true, false),
ExecMode::Hardened => (EffectiveInheritMode::Minimal, true, true),
ExecMode::Custom => (
map_custom_inherit_mode(profile::get_exec_custom_inherit_mode()),
profile::get_exec_custom_deny_dangerous_env(),
profile::get_exec_auto_redact_output(),
),
}
}
fn map_exec_mode_setting(mode: ExecModeSetting) -> ExecMode {
match mode {
ExecModeSetting::Standard => ExecMode::Standard,
ExecModeSetting::Hardened => ExecMode::Hardened,
ExecModeSetting::Custom => ExecMode::Custom,
}
}
fn map_contract_trust_level(level: AuthorityTrustLevel) -> ExecMode {
match level {
AuthorityTrustLevel::Standard => ExecMode::Standard,
AuthorityTrustLevel::Hardened => ExecMode::Hardened,
AuthorityTrustLevel::Custom => ExecMode::Custom,
}
}
fn map_contract_inherit(mode: ContractInheritMode) -> EffectiveInheritMode {
match mode {
ContractInheritMode::Full => EffectiveInheritMode::Full,
ContractInheritMode::Minimal => EffectiveInheritMode::Minimal,
ContractInheritMode::Clean => EffectiveInheritMode::Clean,
}
}
fn map_effective_inherit(mode: &EffectiveInheritMode) -> ContractInheritMode {
match mode {
EffectiveInheritMode::Full => ContractInheritMode::Full,
EffectiveInheritMode::Minimal => ContractInheritMode::Minimal,
EffectiveInheritMode::Clean | EffectiveInheritMode::Only(_) => ContractInheritMode::Clean,
}
}
fn map_resolved_mode_trust_level(mode: ExecMode) -> AuthorityTrustLevel {
match mode {
ExecMode::Standard => AuthorityTrustLevel::Standard,
ExecMode::Hardened => AuthorityTrustLevel::Hardened,
ExecMode::Custom => AuthorityTrustLevel::Custom,
}
}
fn map_custom_inherit_mode(mode: ExecCustomInheritMode) -> EffectiveInheritMode {
match mode {
ExecCustomInheritMode::Full => EffectiveInheritMode::Full,
ExecCustomInheritMode::Minimal => EffectiveInheritMode::Minimal,
ExecCustomInheritMode::Clean => EffectiveInheritMode::Clean,
}
}
fn exec_mode_flag_value(mode: ExecModeSetting) -> &'static str {
match mode {
ExecModeSetting::Standard => "standard",
ExecModeSetting::Hardened => "hardened",
ExecModeSetting::Custom => "custom",
}
}
fn normalize_requested_names(chunks: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for chunk in chunks {
for part in chunk.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty() && !out.iter().any(|existing: &String| existing == trimmed) {
out.push(trimmed.to_string());
}
}
}
out
}
fn resolve_selected_names(
explicit_selected_names: &[String],
contract_allowed_names: &[String],
) -> (Vec<String>, Vec<String>) {
if contract_allowed_names.is_empty() {
return (explicit_selected_names.to_vec(), Vec::new());
}
if explicit_selected_names.is_empty() {
return (contract_allowed_names.to_vec(), Vec::new());
}
let allowed_set: std::collections::HashSet<&str> =
contract_allowed_names.iter().map(String::as_str).collect();
let mut selected = Vec::new();
let mut blocked = Vec::new();
for name in explicit_selected_names {
if allowed_set.contains(name.as_str()) {
selected.push(name.clone());
} else {
blocked.push(name.clone());
}
}
(selected, blocked)
}
fn resolve_required_names(
explicit_required_names: &[String],
contract_required_names: &[String],
contract_allowed_names: &[String],
) -> (Vec<String>, Vec<String>) {
let mut required = contract_required_names.to_vec();
let allowed_set: Option<std::collections::HashSet<&str>> = if contract_allowed_names.is_empty()
{
None
} else {
Some(
contract_allowed_names
.iter()
.map(String::as_str)
.collect::<std::collections::HashSet<_>>(),
)
};
let mut blocked = Vec::new();
for name in explicit_required_names {
if allowed_set
.as_ref()
.is_some_and(|allowed| !allowed.contains(name.as_str()))
{
blocked.push(name.clone());
continue;
}
if !required.iter().any(|existing| existing == name) {
required.push(name.clone());
}
}
(required, blocked)
}
#[allow(clippy::too_many_arguments)]
fn build_exec_audit_entry(
profile: &str,
ns: Option<&str>,
contract: Option<&AuthorityContract>,
cmd_parts: &[String],
resolved_mode: &ResolvedExecTrustMode,
secrets: &std::collections::HashMap<String, String>,
required_names: &[String],
env_mappings: &[ParsedEnvMapping],
) -> AuditEntry {
let mut exec = build_exec_audit_context(
profile,
ns,
contract,
cmd_parts,
resolved_mode,
required_names,
);
let mut injected_secrets: Vec<String> = secrets.keys().cloned().collect();
injected_secrets.sort();
let mut dropped_env_names: Vec<String> = tsenv::sensitive_parent_env_vars()
.into_iter()
.filter(|name| std::env::var(name).is_ok())
.collect();
dropped_env_names.sort();
dropped_env_names.dedup();
exec.injected_secrets = injected_secrets;
if contract.is_none() && exec.allowed_secrets.is_empty() {
exec.allowed_secrets = exec.injected_secrets.clone();
}
exec.dropped_env_names = dropped_env_names;
exec.env_mappings = env_mappings
.iter()
.map(|m| AuditEnvMapping {
env: m.env_var.clone(),
vault_key: m.vault_key.clone(),
})
.collect();
AuditEntry::success(profile, "exec", None).with_context(AuditContext::from_exec(exec))
}
fn build_exec_audit_context(
profile: &str,
ns: Option<&str>,
contract: Option<&AuthorityContract>,
cmd_parts: &[String],
resolved_mode: &ResolvedExecTrustMode,
required_names: &[String],
) -> AuditExecContext {
let mut exec = if let Some(contract) = contract {
AuditExecContext::from_contract(contract)
} else {
AuditExecContext {
contract_name: None,
target: None,
authority_profile: Some(profile.to_string()),
authority_namespace: ns.map(str::to_string),
trust_level: None,
access_profile: None,
inherit: None,
deny_dangerous_env: None,
redact_output: None,
network: None,
allowed_secrets: Vec::new(),
required_secrets: Vec::new(),
injected_secrets: Vec::new(),
missing_required_secrets: Vec::new(),
dropped_env_names: Vec::new(),
env_mappings: Vec::new(),
target_allowed: None,
target_decision: None,
matched_target: None,
deny_reason: None,
}
};
exec.authority_profile = Some(profile.to_string());
exec.authority_namespace = ns.map(str::to_string);
exec.trust_level = Some(map_resolved_mode_trust_level(resolved_mode.mode));
exec.access_profile = Some(
contract
.map(|authority| authority.resolved_exec_policy().access_profile)
.unwrap_or(RbacProfile::ReadWrite),
);
exec.inherit = Some(map_effective_inherit(&resolved_mode.inherit));
exec.deny_dangerous_env = Some(resolved_mode.deny_dangerous_env);
exec.redact_output = Some(resolved_mode.redact_output);
exec.required_secrets = required_names.to_vec();
exec.target = cmd_parts.first().cloned();
if let (Some(authority), Some(command)) = (contract, cmd_parts.first()) {
exec = exec.with_target_evaluation(&authority.evaluate_target(Some(command)));
}
exec
}
#[allow(clippy::too_many_arguments)]
fn append_exec_failure_audit(
profile: &str,
ns: Option<&str>,
contract: Option<&AuthorityContract>,
cmd_parts: &[String],
resolved_mode: &ResolvedExecTrustMode,
required_names: &[String],
missing_required_names: &[String],
deny_reason: DenyReason,
message: &str,
) {
let mut exec = build_exec_audit_context(
profile,
ns,
contract,
cmd_parts,
resolved_mode,
required_names,
);
exec.missing_required_secrets = missing_required_names.to_vec();
exec.deny_reason = Some(deny_reason);
let entry = AuditEntry::failure(profile, "exec", None, message)
.with_context(AuditContext::from_exec(exec));
audit(profile).append(&entry).ok();
}
fn spawn_with_redacted_output(
mut cmd: Command,
secrets: &std::collections::HashMap<String, String>,
) -> Result<i32> {
let redaction_values = compile_redaction_values(secrets);
if redaction_values.is_empty() {
let status = cmd.status()?;
return Ok(status.code().unwrap_or(1));
}
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().context("failed to spawn redacted exec child")?;
let stdout = child
.stdout
.take()
.context("child stdout was not captured for redaction")?;
let stderr = child
.stderr
.take()
.context("child stderr was not captured for redaction")?;
let stdout_thread = spawn_redaction_pump(stdout, false, redaction_values.clone());
let stderr_thread = spawn_redaction_pump(stderr, true, redaction_values);
let status = child.wait()?;
stdout_thread
.join()
.map_err(|_| anyhow::anyhow!("stdout redaction thread panicked"))??;
stderr_thread
.join()
.map_err(|_| anyhow::anyhow!("stderr redaction thread panicked"))??;
Ok(status.code().unwrap_or(1))
}
fn compile_redaction_values(secrets: &std::collections::HashMap<String, String>) -> Vec<String> {
let mut values: Vec<String> = secrets
.values()
.filter(|value| value.len() > 2)
.cloned()
.collect();
values.sort_by_key(|value| std::cmp::Reverse(value.len()));
values.dedup();
values
}
fn spawn_redaction_pump<R: Read + Send + 'static>(
reader: R,
use_stderr: bool,
redaction_values: Vec<String>,
) -> thread::JoinHandle<std::io::Result<()>> {
thread::spawn(move || {
if use_stderr {
let mut writer = std::io::stderr();
pump_redacted_stream(reader, &mut writer, &redaction_values)
} else {
let mut writer = std::io::stdout();
pump_redacted_stream(reader, &mut writer, &redaction_values)
}
})
}
fn pump_redacted_stream<R: Read, W: Write>(
reader: R,
writer: &mut W,
redaction_values: &[String],
) -> std::io::Result<()> {
let mut reader = BufReader::new(reader);
let mut buf = Vec::new();
loop {
buf.clear();
let read = reader.read_until(b'\n', &mut buf)?;
if read == 0 {
break;
}
let text = String::from_utf8_lossy(&buf);
let redacted = redact_text(&text, redaction_values);
writer.write_all(redacted.as_bytes())?;
writer.flush()?;
}
Ok(())
}
fn redact_text(input: &str, redaction_values: &[String]) -> String {
let mut out = input.to_string();
for value in redaction_values {
if out.contains(value) {
out = out.replace(value, "[REDACTED]");
}
}
out
}