exfiltrate 0.4.0

An embeddable debug tool for Rust.
Documentation
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Configuration for the embedded debug server.
//!
//! Everything here has a default that is safe for a program that just called
//! [`begin()`](crate::begin). The reason the type exists is that some of those
//! defaults are the wrong call for *some* programs, and the previous answer —
//! recompile the crate — was not an answer.

use exfiltrate_internal::build_info::{BuildInfo, PROTOCOL_VERSION, PeerRole};

/// What should happen when the debug server cannot open its socket.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum BindFailure {
    /// Print a diagnostic and continue without a debug server.
    ///
    /// This is the default, and the reasoning is one-line: a debugging facility
    /// that kills the program it was added to help debug is worse than no
    /// debugging facility. A taken port usually means a second copy of the same
    /// program is already running, which is a normal thing to do.
    #[default]
    Warn,
    /// Fail the process.
    ///
    /// Opt into this when the debug channel is load-bearing — an integration
    /// test that would otherwise pass while testing nothing, for instance.
    Panic,
    /// Continue with no diagnostic at all.
    Silent,
}

/// Which of the built-in introspection commands to register.
///
/// Every one of these reveals something about the host process, so the split is
/// by how much: [`Batteries::standard`] answers questions about the program's
/// own build and health, and `env` — the one place a secret is actually likely
/// to be sitting — is off until you ask for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Batteries {
    /// Register `build_info`: crate versions, target triple, profile, features.
    pub build_info: bool,
    /// Register `uptime`: process start, wall clock, monotonic since `begin`.
    pub uptime: bool,
    /// Register `threads`: thread names and ids. Native only.
    pub threads: bool,
    /// Register `memory`: resident set size and allocator statistics where available.
    pub memory: bool,
    /// Register `panics`: recently captured panics.
    pub panics: bool,
    /// Register `env`: environment variables and argv.
    ///
    /// Off by default. Values are redacted by name — see
    /// [`Config::env_redact_patterns`] — but redaction is a heuristic and the
    /// only way to be sure a token does not leave the process is not to send it.
    pub env: bool,
}

impl Batteries {
    /// Everything except `env`.
    pub const fn standard() -> Batteries {
        Batteries {
            build_info: true,
            uptime: true,
            threads: true,
            memory: true,
            panics: true,
            env: false,
        }
    }

    /// Register nothing; only `help`, `list` and `terminate` exist.
    pub const fn none() -> Batteries {
        Batteries {
            build_info: false,
            uptime: false,
            threads: false,
            memory: false,
            panics: false,
            env: false,
        }
    }

    /// Everything, including `env`.
    pub const fn all() -> Batteries {
        Batteries {
            env: true,
            ..Batteries::standard()
        }
    }
}

impl Default for Batteries {
    fn default() -> Self {
        Batteries::standard()
    }
}

/// The host program's own identity.
///
/// The `exfiltrate` crate cannot discover this by itself: `env!("CARGO_PKG_NAME")`
/// inside this crate reports `exfiltrate`, not the program that linked it. Build
/// this with [`app_info!`](crate::app_info) from the host crate, or leave it
/// unset and accept the executable's file name.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct AppInfo {
    /// The program name.
    pub name: String,
    /// The program version.
    pub version: String,
}

/// Captures the calling crate's name and version for [`Config::app`].
///
/// ```
/// let config = exfiltrate::Config::default().with_app(exfiltrate::app_info!());
/// assert!(!config.app.unwrap().name.is_empty());
/// ```
#[macro_export]
macro_rules! app_info {
    () => {
        $crate::AppInfo {
            name: env!("CARGO_PKG_NAME").to_string(),
            version: env!("CARGO_PKG_VERSION").to_string(),
        }
    };
}

/// How the embedded debug server should behave.
///
/// ```
/// # #[cfg(target_arch = "wasm32")]
/// wasm_lite::set_panic_hook();
/// let config = exfiltrate::Config::default()
///     .with_addr("127.0.0.1:0")
///     .with_app(exfiltrate::app_info!());
/// exfiltrate::begin_with(config);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Config {
    /// Where to listen (native) or which WebSocket URL to dial (wasm32).
    ///
    /// `None` defers to `EXFILTRATE_ADDR` and then to the compiled-in default.
    /// Port `0` binds an ephemeral port, which is reported on stderr and written
    /// to the instance registry — without the registry an ephemeral port is
    /// undiscoverable and therefore useless.
    ///
    /// Natively this may also name a transport other than TCP, which is how a
    /// sandbox that forbids `bind(2)` on a port is answered:
    ///
    /// | Address | Transport |
    /// | --- | --- |
    /// | `127.0.0.1:1337` | TCP, as before |
    /// | `unix:/run/user/1000/app.sock` | a Unix socket; the containing directory is the access control |
    /// | `unix:` | a Unix socket at the default path beside the instance registry |
    /// | `unix:@name` | a Linux abstract-namespace socket, with no file to clean up |
    /// | `fd:3` | a connected `socketpair(2)` the parent process passed in |
    ///
    /// See [`exfiltrate_internal::transport::Address`].
    pub addr: Option<String>,
    /// What to do when the socket cannot be opened.
    pub on_bind_failure: BindFailure,
    /// The host program's identity, for the handshake and `build_info`.
    pub app: Option<AppInfo>,
    /// Which built-in introspection commands to register.
    pub batteries: Batteries,
    /// How many captured log records to retain.
    ///
    /// Retained for API compatibility. Log capture moved to the
    /// `logwise_agent_exfiltrate` package, which takes its own capacity through
    /// `logwise_agent_exfiltrate::Config`.
    pub log_capacity: usize,
    /// How many captured panics to retain.
    pub panic_capacity: usize,
    /// How many events to queue per subscriber before dropping the oldest.
    pub event_queue_capacity: usize,
    /// Substrings that mark an environment variable's value as secret.
    ///
    /// Matched case-insensitively against the variable name. Only consulted when
    /// [`Batteries::env`] is on.
    pub env_redact_patterns: Vec<String>,
    /// The shared token a client must prove it holds, if any.
    ///
    /// `None` defers to `$EXFILTRATE_TOKEN`, and if that is unset there is no
    /// token — there is deliberately no default, because a default token is not
    /// a credential, it is a formality anyone who reads the source can satisfy.
    ///
    /// Absent a token, an address that is reachable from outside this machine
    /// gets one invented for it and printed on stderr, because reaching the port
    /// would otherwise be the whole of the access control and a debug command is
    /// not read-only: `terminate` exits the process, and a file response writes
    /// bytes into the CLI's working directory. A loopback address, a Unix socket
    /// and an inherited descriptor need no token — the network stack, the
    /// filesystem and the parent process already answer the question.
    ///
    /// A token authenticates; it does not encrypt. See
    /// [`exfiltrate_internal::auth`].
    #[cfg(not(target_arch = "wasm32"))]
    pub token: Option<String>,
    /// Whether to print the token on stderr when the server starts.
    ///
    /// Set by [`Config::with_generated_token`], which is the case where printing
    /// it is the only way anyone could learn it. A token you configured
    /// yourself is not printed, because it would then also be in the logs.
    #[cfg(not(target_arch = "wasm32"))]
    pub announce_token: bool,
    /// Whether to advertise this instance in the runtime registry.
    ///
    /// The registry is a file per process under `$XDG_RUNTIME_DIR/exfiltrate`
    /// (or the temp directory), listed by `exfiltrate instances`. It is what
    /// makes several debugged programs on one machine addressable at all.
    pub instance_registry: bool,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            addr: None,
            on_bind_failure: BindFailure::default(),
            app: None,
            batteries: Batteries::default(),
            log_capacity: 10_000,
            panic_capacity: 64,
            event_queue_capacity: 256,
            env_redact_patterns: default_redact_patterns(),
            #[cfg(not(target_arch = "wasm32"))]
            token: None,
            #[cfg(not(target_arch = "wasm32"))]
            announce_token: false,
            instance_registry: true,
        }
    }
}

/// The variable-name substrings treated as secret by default.
pub fn default_redact_patterns() -> Vec<String> {
    [
        "secret",
        "token",
        "password",
        "passwd",
        "key",
        "credential",
        "auth",
        "session",
        "cookie",
        "private",
    ]
    .into_iter()
    .map(str::to_string)
    .collect()
}

impl Config {
    /// Sets the listen address or WebSocket URL. See [`Config::addr`].
    pub fn with_addr(mut self, addr: impl Into<String>) -> Config {
        self.addr = Some(addr.into());
        self
    }

    /// Sets the token a client must prove it holds. See [`Config::token`].
    #[cfg(not(target_arch = "wasm32"))]
    pub fn with_token(mut self, token: impl Into<String>) -> Config {
        self.token = Some(token.into());
        self
    }

    /// Generates a token for this run and prints it on stderr at startup.
    ///
    /// You do not need this to debug over the network — an address that is
    /// reachable from elsewhere already generates and prints one. Reach for it
    /// when you want a credential on an address that would not otherwise need
    /// one: a loopback port on a shared machine, say, where every local user can
    /// reach 127.0.0.1.
    ///
    /// The program prints two groups of five characters, you type them into the
    /// CLI, and they are gone when the process exits. Fifty bits against a KDF
    /// that runs one guess at a time is not something anyone brute-forces.
    ///
    /// If randomness is unavailable the token is left unset rather than being
    /// replaced with something predictable.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn with_generated_token(mut self) -> Config {
        match exfiltrate_internal::auth::generate_token() {
            Ok(token) => {
                self.token = Some(token);
                self.announce_token = true;
            }
            Err(error) => {
                crate::diagnostic(&format!(
                    "exfiltrate: could not generate a token ({error}); \
                     the server will refuse any non-loopback address."
                ));
            }
        }
        self
    }

    /// Sets the host program's identity. See [`app_info!`](crate::app_info).
    pub fn with_app(mut self, app: AppInfo) -> Config {
        self.app = Some(app);
        self
    }

    /// Sets which built-in introspection commands to register.
    pub fn with_batteries(mut self, batteries: Batteries) -> Config {
        self.batteries = batteries;
        self
    }

    /// Sets what happens when the socket cannot be opened.
    pub fn with_bind_failure(mut self, on_bind_failure: BindFailure) -> Config {
        self.on_bind_failure = on_bind_failure;
        self
    }

    /// Whether an environment variable's value should be hidden.
    pub fn should_redact(&self, name: &str) -> bool {
        let name = name.to_ascii_lowercase();
        self.env_redact_patterns
            .iter()
            .any(|pattern| name.contains(&pattern.to_ascii_lowercase()))
    }

    /// Builds the [`BuildInfo`] this process presents in the handshake.
    pub fn build_info(&self) -> BuildInfo {
        let (app_name, app_version) = match &self.app {
            Some(app) => (app.name.clone(), app.version.clone()),
            None => (executable_name(), String::new()),
        };
        BuildInfo {
            protocol_version: PROTOCOL_VERSION,
            role: PeerRole::Server,
            exfiltrate_version: env!("CARGO_PKG_VERSION").to_string(),
            app_name,
            app_version,
            target_triple: env!("EXFILTRATE_TARGET").to_string(),
            profile: env!("EXFILTRATE_PROFILE").to_string(),
            features: enabled_features(),
            git_sha: non_empty(env!("EXFILTRATE_GIT_SHA")),
            build_timestamp: non_empty(env!("EXFILTRATE_BUILD_TIMESTAMP")),
        }
    }
}

fn non_empty(value: &str) -> Option<String> {
    if value.is_empty() {
        None
    } else {
        Some(value.to_string())
    }
}

/// The cargo features enabled on *this* crate.
pub fn enabled_features() -> Vec<String> {
    // Written as a list of `cfg`-conditional entries so adding a feature is a
    // one-line change here and nowhere else.
    // Currently empty: the `logwise` feature moved out to the
    // `logwise_agent_exfiltrate` package, which registers its own commands
    // rather than being compiled into this one. Kept as a list so adding a
    // feature stays a one-line change here and nowhere else.
    Vec::new()
}

/// The executable's file name, used when the host program did not identify itself.
fn executable_name() -> String {
    #[cfg(not(target_arch = "wasm32"))]
    {
        std::env::current_exe()
            .ok()
            .and_then(|path| {
                path.file_stem()
                    .map(|stem| stem.to_string_lossy().into_owned())
            })
            .unwrap_or_default()
    }
    #[cfg(target_arch = "wasm32")]
    {
        // A wasm module has no argv[0] worth reporting, and guessing from the
        // page URL would name the page rather than the program.
        String::new()
    }
}

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

    #[test]
    fn the_default_config_keeps_env_out_of_the_battery() {
        let config = Config::default();
        assert!(config.batteries.build_info);
        assert!(!config.batteries.env, "env must be opt-in");
    }

    #[test]
    fn redaction_matches_on_a_case_insensitive_substring() {
        let config = Config::default();
        assert!(config.should_redact("AWS_SECRET_ACCESS_KEY"));
        assert!(config.should_redact("github_token"));
        assert!(config.should_redact("MY_Private_Thing"));
        assert!(!config.should_redact("HOME"));
        assert!(!config.should_redact("PATH"));
    }

    #[test]
    fn build_info_reports_this_crate_and_this_target() {
        let info = Config::default().build_info();
        assert_eq!(info.exfiltrate_version, env!("CARGO_PKG_VERSION"));
        assert!(!info.target_triple.is_empty());
        assert!(!info.profile.is_empty());
        assert_eq!(info.role, PeerRole::Server);
    }

    #[test]
    fn an_explicit_app_identity_beats_the_executable_name() {
        let config = Config::default().with_app(AppInfo {
            name: "demo".to_string(),
            version: "1.2.3".to_string(),
        });
        let info = config.build_info();
        assert_eq!(info.app_name, "demo");
        assert_eq!(info.app_version, "1.2.3");
    }

    #[test]
    fn batteries_presets_differ_only_where_documented() {
        assert!(Batteries::all().env);
        assert!(!Batteries::standard().env);
        assert_eq!(
            Batteries::none(),
            Batteries {
                build_info: false,
                uptime: false,
                threads: false,
                memory: false,
                panics: false,
                env: false,
            }
        );
    }
}