fix: wipe credentials in the connect path and keep release builds out of dumps and debuggers

- Credential callbacks (SecretFn) now return Zeroizing<String>, as do the
  session-only/pending caches they read and the RDP client's password,
  so Nexus's own copies are wiped; a plain String copy is made only
  where a library API needs one (SSH password auth, IRC, VNC, CredSSP).
- Release builds disable core dumps (RLIMIT_CORE 0) and, on Linux, mark
  the process non-dumpable (PR_SET_DUMPABLE 0), which also blocks ptrace
  attach and /proc/<pid>/mem reads by other processes of the same user.
  Debug builds are unchanged so they stay debuggable. Verified on the
  release binary: core limit 0, /proc/<pid>/{environ,mem} root-owned and
  unreadable, local shells (including cwd capture) still work.

Closes the remaining parts of SEC-20261012-007 that the app controls;
egui's internal text buffers remain outside its reach.
This commit is contained in:
Andy Dixon
2026-10-12 17:19:27 +01:00
parent aacd44a275
commit fda804e3f5
8 changed files with 43 additions and 17 deletions
+6 -6
View File
@@ -55,29 +55,29 @@ impl Manager {
if mode == SecretMode::Saved {
let mut st = state.lock();
if let Some(v) = st.pending.get(&key) {
return Ok(v.to_string());
return Ok(v.clone());
}
if !st.rejected.remove(&key)
&& let Some(v) = secrets.get(&key)
{
st.used.insert(key.clone());
store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential"));
return Ok(v);
return Ok(Zeroizing::new(v));
}
}
if mode == SecretMode::SessionOnly
&& let Some(v) = state.lock().session_only.get(&key).map(|v| v.to_string())
&& let Some(v) = state.lock().session_only.get(&key).cloned()
{
return Ok(v);
}
let v = prompt(&format!("Enter {kind}:"))?;
let v = Zeroizing::new(prompt(&format!("Enter {kind}:"))?);
let mut st = state.lock();
match mode {
SecretMode::Saved => {
st.pending.insert(key.clone(), Zeroizing::new(v.clone()));
st.pending.insert(key.clone(), v.clone());
}
SecretMode::SessionOnly => {
st.session_only.insert(key.clone(), Zeroizing::new(v.clone()));
st.session_only.insert(key.clone(), v.clone());
}
SecretMode::AskEveryTime => {}
}
+2 -2
View File
@@ -838,7 +838,7 @@ mod tests {
// Two separate resolutions (as two connects of the same session make) prompt once.
for _ in 0..2 {
let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
assert_eq!(f().unwrap(), "hunter2");
assert_eq!(f().unwrap().as_str(), "hunter2");
}
assert_eq!(asked.load(Ordering::SeqCst), 1);
// Ask-every-time still prompts each time.
@@ -848,7 +848,7 @@ mod tests {
// An explicit reconnect carries the cache to the new session id.
let new = m.reconnect("s1").unwrap();
let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
assert_eq!(f().unwrap(), "hunter2");
assert_eq!(f().unwrap().as_str(), "hunter2");
assert_eq!(asked.load(Ordering::SeqCst), 2);
m.close_all();
let _ = std::fs::remove_dir_all(dir);
+5 -2
View File
@@ -125,11 +125,14 @@ impl Manager {
let server_pass = if ic.server_password_required {
self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())()
.map_err(|e| format!("IRC server password: {e}"))?
.to_string()
} else {
String::new()
};
let ns_pass = if ic.nick_serv_identify {
self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))?
self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())()
.map_err(|e| format!("NickServ password: {e}"))?
.to_string()
} else {
String::new()
};
@@ -403,7 +406,7 @@ pub(super) fn connect_vnc_client(
Ok(()) => Ok(c),
// Ask for the password only when the server wants one.
Err(e) if e.to_string().to_lowercase().contains("password") => {
cfg.password = Some(password()?);
cfg.password = Some(password()?.to_string());
let c = clients::vnc::Client::new(cfg);
c.connect()?;
Ok(c)