exfiltrate 0.4.0

An embeddable debug tool for Rust.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Implements the native `env` command.
#![cfg(not(target_arch = "wasm32"))]
use crate::command::{Command, Response};
use exfiltrate_internal::args::{ArgKind, ArgSpec, ParsedArgs};

/// The `env` command.
///
/// This is the one battery that is off by default. Environment variables are
/// where credentials live, `exfiltrate` sends whatever it is asked for over a
/// socket, and redaction by name is a heuristic that a variable called
/// `DEPLOY_URL` containing a signed URL will defeat. So it is opt-in at
/// [`Batteries::env`](crate::Batteries::env), values are redacted by default,
/// and showing them takes a second explicit flag.
pub(crate) struct Env;

static ARGS: &[ArgSpec] = &[
    ArgSpec::flag(
        "filter",
        "only show variables whose name contains this substring",
        ArgKind::String,
    ),
    ArgSpec::flag(
        "show-secrets",
        "print the values of variables that look secret",
        ArgKind::Bool,
    ),
    ArgSpec::flag(
        "argv",
        "show the command line instead of the environment",
        ArgKind::Bool,
    ),
];

/// What replaces a redacted value.
const REDACTED: &str = "<redacted>";

impl Command for Env {
    fn name(&self) -> &'static str {
        "env"
    }

    fn short_description(&self) -> &'static str {
        "Shows the process environment and command line, with secret-looking values redacted."
    }

    fn full_description(&self) -> &'static str {
        "Shows the environment variables and command line of the process being debugged.

This command is NOT registered by default. Enable it with
`Config::default().with_batteries(Batteries::all())`, or by setting `batteries.env`.

Values whose variable name matches a redaction pattern — `secret`, `token`, `key`,
`password` and friends, configurable via `Config::env_redact_patterns` — are printed
as `<redacted>`. `--show-secrets` prints them in full, and does so over whatever
transport you are using, so decide deliberately.

Redaction is a heuristic. A variable named `DEPLOY_URL` holding a signed URL is not
matched by any name pattern and will be printed. If a value must not leave the
process, do not enable this command."
    }

    fn args(&self) -> &'static [ArgSpec] {
        ARGS
    }

    fn execute(&self, args: Vec<String>) -> Result<Response, Response> {
        let parsed = ParsedArgs::parse(self.args(), args).map_err(Response::String)?;
        let config = crate::config_snapshot();

        if parsed.boolean("argv") {
            let mut out = String::from("argv:\n");
            for (index, arg) in std::env::args().enumerate() {
                out.push_str(&format!("  [{index}] {arg}\n"));
            }
            return Ok(out.into());
        }

        let mut vars: Vec<(String, String)> = std::env::vars().collect();
        vars.sort();
        Ok(render(
            &vars,
            parsed.get("filter"),
            parsed.boolean("show-secrets"),
            |name| config.should_redact(name),
        )
        .into())
    }
}

/// Renders the variable listing.
///
/// The redaction predicate is a parameter so the policy can be tested without a
/// process environment to arrange.
fn render(
    vars: &[(String, String)],
    filter: Option<&str>,
    show_secrets: bool,
    should_redact: impl Fn(&str) -> bool,
) -> String {
    let shown: Vec<&(String, String)> = vars
        .iter()
        .filter(|(name, _)| filter.is_none_or(|needle| name.contains(needle)))
        .collect();
    if shown.is_empty() {
        return match filter {
            Some(needle) => format!("no environment variable name contains {needle:?}\n"),
            None => "the environment is empty\n".to_string(),
        };
    }

    let mut redacted_count = 0usize;
    let mut out = String::new();
    for (name, value) in &shown {
        if should_redact(name) && !show_secrets {
            redacted_count += 1;
            out.push_str(&format!("{name}={REDACTED}\n"));
        } else {
            out.push_str(&format!("{name}={value}\n"));
        }
    }
    if redacted_count > 0 {
        out.push_str(&format!(
            "\n{redacted_count} value(s) redacted by name. Use --show-secrets to print them.\n"
        ));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Vec<(String, String)> {
        [
            ("HOME", "/home/demo"),
            ("AWS_SECRET_ACCESS_KEY", "hunter2"),
            ("PATH", "/usr/bin"),
        ]
        .into_iter()
        .map(|(name, value)| (name.to_string(), value.to_string()))
        .collect()
    }

    fn looks_secret(name: &str) -> bool {
        crate::Config::default().should_redact(name)
    }

    #[test]
    fn secret_looking_values_are_hidden_and_the_count_is_reported() {
        let text = render(&sample(), None, false, looks_secret);
        assert!(text.contains("HOME=/home/demo"), "{text}");
        assert!(
            text.contains(&format!("AWS_SECRET_ACCESS_KEY={REDACTED}")),
            "{text}"
        );
        assert!(!text.contains("hunter2"), "{text}");
        assert!(text.contains("1 value(s) redacted"), "{text}");
    }

    #[test]
    fn show_secrets_prints_them_and_stops_claiming_anything_was_redacted() {
        let text = render(&sample(), None, true, looks_secret);
        assert!(text.contains("AWS_SECRET_ACCESS_KEY=hunter2"), "{text}");
        assert!(!text.contains("redacted"), "{text}");
    }

    #[test]
    fn the_filter_narrows_by_name() {
        let text = render(&sample(), Some("PATH"), false, looks_secret);
        assert!(text.contains("PATH=/usr/bin"), "{text}");
        assert!(!text.contains("HOME"), "{text}");
    }

    #[test]
    fn an_empty_result_says_why_rather_than_printing_nothing() {
        let text = render(&sample(), Some("NOPE"), false, looks_secret);
        assert!(
            text.contains("no environment variable name contains \"NOPE\""),
            "{text}"
        );
    }

    #[test]
    fn the_command_reads_the_real_environment_and_still_redacts() {
        let text = Env.execute(Vec::new()).unwrap().into_string();
        assert!(!text.is_empty());
        let argv = Env
            .execute(vec!["--argv".to_string()])
            .unwrap()
            .into_string();
        assert!(argv.starts_with("argv:"), "{argv}");
    }
}