Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
94 changes: 87 additions & 7 deletions implants/lib/eldritch/eldritch/src/bindings_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@ fn create_interp() -> Interpreter {
jwt: "a test jwt".to_string(),
};
let backend = Arc::new(EmptyAssets {});
Interpreter::new().with_default_libs().with_context(
agent_mock,
eldritch_agent::Context::Task(task_context),
vec![],
backend,
)
// with_default_libs registers Std then Fake (fake wins).
// with_context re-registers Std (std wins – bug). Chain with_fake_agent
// to make fake win again so tests use fake impls.
Interpreter::new()
.with_default_libs()
.with_context(
agent_mock,
eldritch_agent::Context::Task(task_context),
vec![],
backend,
)
.with_fake_agent()
}
#[cfg(not(feature = "stdlib"))]
{
Interpreter::new()
Interpreter::new().with_fake_agent()
}
}

Expand Down Expand Up @@ -67,20 +73,23 @@ fn test_file_bindings() {
"is_dir",
"is_file",
"list",
"list_named_pipes",
"list_recent",
"mkdir",
"move",
"parent_dir",
"pwd",
"read",
"read_binary",
"read_named_pipe",
"remove",
"replace",
"replace_all",
"temp_file",
"template",
"template_str",
"timestomp",
"tmp_dir",
"write",
"write_binary",
],
Expand Down Expand Up @@ -136,10 +145,81 @@ fn test_pivot_bindings() {
"ssh_copy",
"ssh_deploy",
"ssh_exec",
"ssh_session",
],
);
}

#[test]
fn test_ssh_session_object_methods() {
// with_fake_agent returns FakeSshSessionHandle which has exec + close
let mut interp = create_interp();
let code = r#"
s = pivot.ssh_session("127.0.0.1", 22, "root", "pass", None, None, None)
dir(s)
"#;
let val = interp
.interpret(code)
.expect("interpret ssh_session failed");
if let Value::List(l) = val {
let list = l.read();
let actual: Vec<String> = list
.iter()
.map(|v| v.to_string().replace("\"", ""))
.collect();
assert!(
actual.contains(&"exec".to_string()),
"ssh_session dir should contain exec, got {actual:?}"
);
assert!(
actual.contains(&"close".to_string()),
"ssh_session dir should contain close, got {actual:?}"
);
} else {
panic!("Expected list for dir(ssh_session)");
}
}

#[test]
fn test_ssh_session_fake_exec_and_close() {
let mut interp = create_interp();
// Fake exec returns stdout="fake output"
let code = r#"
s = pivot.ssh_session("127.0.0.1", 22, "root", "pass", None, None, None)
r = s.exec("whoami")
r["stdout"]
"#;
let val = interp.interpret(code).expect("interpret exec failed");
assert_eq!(val.to_string(), "fake output");

// After close, exec should raise.
let code2 = r#"
s = pivot.ssh_session("127.0.0.1", 22, "root", "pass", None, None, None)
s.close()
s.exec("whoami")
"#;
let res2 = interp.interpret(code2);
assert!(
res2.is_err(),
"expected error after close, got ok: {res2:?}"
);
}

#[test]
fn test_ssh_session_fake_reuse_multiple_execs() {
let mut interp = create_interp();
let code = r#"
s = pivot.ssh_session("127.0.0.1", 22, "root", "pass", None, None, None)
r1 = s.exec("whoami")
r2 = s.exec("id")
r3 = s.exec("hostname")
s.close()
r1["stdout"] + "|" + r2["stdout"] + "|" + r3["stdout"]
"#;
let val = interp.interpret(code).expect("multi exec should succeed");
assert_eq!(val.to_string(), "fake output|fake output|fake output");
}

#[test]
fn test_assets_bindings() {
check_bindings("assets", &["copy", "list", "read", "read_binary"]);
Expand Down
3 changes: 2 additions & 1 deletion implants/lib/eldritch/stdlib/eldritch-libpivot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ tokio = { workspace = true, features = [
], optional = true }
pnet = { workspace = true, optional = true }
pb = { workspace = true, optional = true }
spin = { workspace = true, features = ["mutex"], optional = true }

[target.'cfg(not(target_os = "freebsd"))'.dependencies]
listeners = { workspace = true, optional = true }
Expand All @@ -48,5 +49,5 @@ stdlib = [
"tokio",
"pnet",
]
fake_bindings = []
fake_bindings = ["dep:spin"]
print_debug = []
59 changes: 58 additions & 1 deletion implants/lib/eldritch/stdlib/eldritch-libpivot/src/fake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,50 @@ use super::PivotLibrary;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::string::ToString;
use alloc::sync::Arc;
use alloc::vec::Vec;
use eldritch_core::Value;
use eldritch_macros::eldritch_library_impl;
use eldritch_macros::{eldritch_library, eldritch_library_impl, eldritch_method};

// ──────────────────────────────────────────────────────────────────────────────
// Fake ssh_session handle (returned as Foreign Value)
// ──────────────────────────────────────────────────────────────────────────────

#[eldritch_library("ssh_session")]
pub trait FakeSshSessionLibrary {
#[eldritch_method]
fn exec(&self, _command: String) -> Result<BTreeMap<String, Value>, String>;
#[eldritch_method]
fn close(&self) -> Result<(), String>;
}

#[derive(Debug)]
#[eldritch_library_impl(FakeSshSessionLibrary)]
pub struct FakeSshSessionHandle {
closed: spin::Mutex<bool>,
}

impl FakeSshSessionLibrary for FakeSshSessionHandle {
fn exec(&self, _command: String) -> Result<BTreeMap<String, Value>, String> {
if *self.closed.lock() {
return Err("ssh_session is closed".to_string());
}
let mut map = BTreeMap::new();
map.insert("status".into(), Value::Int(0));
map.insert("stdout".into(), Value::String("fake output".to_string()));
map.insert("stderr".into(), Value::String("".to_string()));
Ok(map)
}

fn close(&self) -> Result<(), String> {
*self.closed.lock() = true;
Ok(())
}
}

// ──────────────────────────────────────────────────────────────────────────────
// Fake Pivot library
// ──────────────────────────────────────────────────────────────────────────────

#[derive(Default, Debug)]
#[eldritch_library_impl(PivotLibrary)]
Expand Down Expand Up @@ -48,6 +89,22 @@ impl PivotLibrary for PivotLibraryFake {
Ok("Success".into())
}

fn ssh_session(
&self,
_target: String,
_port: i64,
_username: String,
_password: Option<String>,
_key: Option<String>,
_key_password: Option<String>,
_timeout: Option<i64>,
) -> Result<Value, String> {
let handle = FakeSshSessionHandle {
closed: spin::Mutex::new(false),
};
Ok(Value::Foreign(Arc::new(handle)))
}

fn port_scan(
&self,
_target_cidrs: Vec<String>,
Expand Down
35 changes: 35 additions & 0 deletions implants/lib/eldritch/stdlib/eldritch-libpivot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,41 @@ pub trait PivotLibrary {
timeout: Option<i64>,
) -> Result<String, String>;

#[allow(clippy::too_many_arguments)]
#[eldritch_method]
/// Creates a persistent SSH session for reuse across multiple commands.
///
/// Unlike `ssh_exec` which opens a new connection per call, this establishes
/// one connection and returns a session object whose `exec()` can be called
/// repeatedly. This reduces authentication log noise and handshake latency.
///
/// **Parameters**
/// - `target` (`str`): The remote host IP or hostname.
/// - `port` (`int`): The SSH port (usually 22).
/// - `username` (`str`): SSH username.
/// - `password` (`Option<str>`): SSH password (optional).
/// - `key` (`Option<str>`): SSH private key (optional).
/// - `key_password` (`Option<str>`): Password for the private key (optional).
/// - `timeout` (`Option<int>`): Connection timeout in seconds (optional, defaults to 3).
///
/// **Returns**
/// - `<ssh_session>`: A session object with methods:
/// - `exec(command: str) -> Dict{stdout, stderr, status}`
/// - `close() -> None`
///
/// **Errors**
/// - Returns an error string if connection fails.
fn ssh_session(
&self,
target: String,
port: i64,
username: String,
password: Option<String>,
key: Option<String>,
key_password: Option<String>,
timeout: Option<i64>,
) -> Result<Value, String>;

#[eldritch_method]
/// Scans TCP/UDP ports on target hosts.
///
Expand Down
23 changes: 23 additions & 0 deletions implants/lib/eldritch/stdlib/eldritch-libpivot/src/std/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod port_scan_impl;
pub mod ssh_copy_impl;
pub mod ssh_deploy_impl;
pub mod ssh_exec_impl;
pub mod ssh_session_impl;

use alloc::collections::BTreeMap;
use alloc::string::String;
Expand Down Expand Up @@ -111,6 +112,28 @@ impl PivotLibrary for StdPivotLibrary {
.map_err(|e| e.to_string())
}

fn ssh_session(
&self,
target: String,
port: i64,
username: String,
password: Option<String>,
key: Option<String>,
key_password: Option<String>,
timeout: Option<i64>,
) -> Result<Value, String> {
ssh_session_impl::ssh_session(
target,
port as i32,
username,
password,
key,
key_password,
timeout.map(|t| t as u32),
)
.map_err(|e| e.to_string())
}

fn port_scan(
&self,
target_cidrs: Vec<String>,
Expand Down
Loading
Loading