Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9943f15
fix: audit findings — swapped stderr/stdout labels, missing attachmen…
rafaelfiguereod-stack May 4, 2026
ce06a7f
Merge pull request #1 from rafaelfiguereod-stack/claude/setup-vibe-ka…
rafaelfiguereod-stack May 6, 2026
b2f7993
Potential fix for code scanning alert no. 29: Server-side request for…
rafaelfiguereod-stack May 6, 2026
978bd9a
Merge pull request #2 from rafaelfiguereod-stack/alert-autofix-29
rafaelfiguereod-stack May 6, 2026
de3356d
chore(deps): bump the npm_and_yarn group across 1 directory with 5 up…
dependabot[bot] May 6, 2026
88b2324
Merge pull request #3 from rafaelfiguereod-stack/dependabot/npm_and_y…
rafaelfiguereod-stack May 6, 2026
212d1ad
chore(deps): bump the npm_and_yarn group across 1 directory with 3 up…
dependabot[bot] May 11, 2026
8b236cf
Merge pull request #4 from rafaelfiguereod-stack/dependabot/npm_and_y…
rafaelfiguereod-stack May 23, 2026
dd1496d
Create SECURITY.md
rafaelfiguereod-stack May 23, 2026
15629a7
fix(deps): add pnpm overrides to resolve 36 npm vulnerabilities
rafaelfiguereod-stack May 28, 2026
d6de928
Merge pull request #5 from rafaelfiguereod-stack/ai/fix-npm-vulnerabi…
rafaelfiguereod-stack May 28, 2026
3b9df5f
fix(deps): scope glob override to v10 and fix SECURITY.md advisory URL
rafaelfiguereod-stack May 28, 2026
05ef97c
Merge pull request #6 from rafaelfiguereod-stack/ai/fix-vulnerability…
rafaelfiguereod-stack May 28, 2026
e4049a0
fix: security audit remediation — serialize PATH mutation, add safety…
claude Jun 10, 2026
b3dcabd
fix: patch critical shell-quote command injection (CVE-2026-9277)
claude Jun 10, 2026
6a920d4
fix: bump aws-lc to patched release (5 RUSTSEC advisories, 3 high)
claude Jun 10, 2026
f56526c
fix: bump rustls-webpki 0.103.10 -> 0.103.13 (3 RUSTSEC advisories)
claude Jun 10, 2026
90c0cea
fix: upgrade russh 0.48 -> 0.61 (RUSTSEC-2026-0153/0154, 7.5 high)
claude Jun 10, 2026
03ffe1b
Merge pull request #7 from rafaelfiguereod-stack/claude/setup-vibe-ka…
rafaelfiguereod-stack Jun 10, 2026
5109536
test: add russh 0.61 SSH/SFTP smoke tests for embedded-ssh
claude Jun 10, 2026
c281083
Merge pull request #8 from rafaelfiguereod-stack/claude/setup-vibe-ka…
rafaelfiguereod-stack Jun 10, 2026
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
1,577 changes: 1,080 additions & 497 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ ts-rs = { git = "https://github.com/xazukx/ts-rs.git", branch = "use-ts-enum", f
schemars = { version = "1.0.4", features = ["derive", "chrono04", "uuid1", "preserve_order"] }
serde_with = "3"
async-trait = "0.1"
aws-lc-sys = "=0.37.0"
aws-lc-rs = "=1.16.0"
# Pinned to patched releases: aws-lc-sys >=0.38.0 fixes RUSTSEC-2026-0044/0045/0046/0047/0048
# (PKCS7_verify bypass, CRL scope, X.509 name-constraint bypass, AES-CCM timing side-channel).
aws-lc-sys = "=0.41.0"
aws-lc-rs = "=1.17.0"

[profile.release]
debug = 1
Expand Down
20 changes: 20 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Security Policy

## Supported Versions

The latest release is always supported with security updates. Older releases
receive fixes on a best-effort basis.

| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |

## Reporting a Vulnerability

Please report security vulnerabilities by opening a **private** GitHub Security
Advisory at `https://github.com/BloopAI/vibe-kanban/security/advisories/new`.

Include a description of the issue, steps to reproduce, and your assessment of
impact. You will receive an acknowledgement within 72 hours. If the report is
accepted, a patch will be released as soon as possible and you will be credited
in the release notes.
12 changes: 7 additions & 5 deletions crates/embedded-ssh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ edition = "2024"

[dependencies]
relay-control = { path = "../relay-control" }
russh = "0.48"
russh-keys = "0.48"
russh-sftp = "2.0"
ssh-key = { version = "0.6", features = ["ed25519"] }
russh = "0.61"
russh-sftp = "2.3"
# Pinned to match russh 0.61's `=0.7.0-rc.10` so KeypairData types are identical.
ssh-key = { version = "=0.7.0-rc.10", features = ["ed25519"] }
ed25519-dalek = "2.2.0"
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }

[dev-dependencies]
russh-sftp = "2.3"
4 changes: 2 additions & 2 deletions crates/embedded-ssh/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ pub fn build_config(signing_key: &SigningKey) -> Arc<Config> {
let ed25519_private = Ed25519PrivateKey::from_bytes(&signing_key.to_bytes());
let keypair = Ed25519Keypair::from(ed25519_private);
let keypair_data = KeypairData::Ed25519(keypair);
let host_key = russh_keys::PrivateKey::new(keypair_data, "").expect("valid Ed25519 key");
let host_key = russh::keys::PrivateKey::new(keypair_data, "").expect("valid Ed25519 key");

Arc::new(Config {
keys: vec![host_key],
auth_rejection_time: Duration::from_secs(1),
auth_rejection_time_initial: Some(Duration::from_secs(0)),
inactivity_timeout: Some(Duration::from_secs(600)),
keepalive_interval: Some(Duration::from_secs(30)),
methods: russh::MethodSet::PUBLICKEY,
methods: russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]),
..Default::default()
})
}
18 changes: 10 additions & 8 deletions crates/embedded-ssh/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@

use std::{collections::HashMap, process::Stdio};

use async_trait::async_trait;
use relay_control::signing::RelaySigningService;
use russh::{
Channel, ChannelId, CryptoVec, Pty,
Channel, ChannelId, Pty,
keys::PublicKey,
server::{Auth, Msg, Session},
};
use russh_keys::PublicKey;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
process::Command,
Expand Down Expand Up @@ -126,8 +125,7 @@ impl SshSessionHandler {
match stdout.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
if handle.data(channel_id, data).await.is_err() {
if handle.data(channel_id, buf[..n].to_vec()).await.is_err() {
break;
}
}
Expand All @@ -147,8 +145,11 @@ impl SshSessionHandler {
match stderr.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
if handle.extended_data(channel_id, 1, data).await.is_err() {
if handle
.extended_data(channel_id, 1, buf[..n].to_vec())
.await
.is_err()
{
break;
}
}
Expand Down Expand Up @@ -207,7 +208,6 @@ impl Drop for SshSessionHandler {
}
}

#[async_trait]
impl russh::server::Handler for SshSessionHandler {
type Error = anyhow::Error;

Expand All @@ -222,6 +222,7 @@ impl russh::server::Handler for SshSessionHandler {
None => {
return Ok(Auth::Reject {
proceed_with_methods: None,
partial_success: false,
});
}
};
Expand All @@ -239,6 +240,7 @@ impl russh::server::Handler for SshSessionHandler {
tracing::debug!("SSH auth rejected: no matching signing session");
Ok(Auth::Reject {
proceed_with_methods: None,
partial_success: false,
})
}
}
Expand Down
13 changes: 8 additions & 5 deletions crates/embedded-ssh/src/sftp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::{collections::HashMap, fs as std_fs, path::PathBuf};

use russh_sftp::protocol::{
Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version,
use russh_sftp::{
protocol::{
Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version,
},
server::StatusReply,
};
use tokio::{
fs,
Expand Down Expand Up @@ -54,9 +57,9 @@ impl From<std::io::Error> for SftpError {
}
}

impl From<SftpError> for StatusCode {
fn from(err: SftpError) -> StatusCode {
err.code
impl From<SftpError> for StatusReply {
fn from(err: SftpError) -> StatusReply {
StatusReply::new(err.code).with_message(err.message)
}
}

Expand Down
198 changes: 198 additions & 0 deletions crates/embedded-ssh/tests/ssh_smoke.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! End-to-end smoke tests for the embedded SSH server against a real russh
//! 0.61 client over a loopback TCP socket.
//!
//! These exercise the runtime paths that only `cargo build`/`clippy` can't
//! catch after the russh 0.48 -> 0.61 upgrade:
//! * Ed25519 host-key construction (`russh::keys::PrivateKey::new` with the
//! `ssh-key 0.7` `KeypairData` types).
//! * Public-key auth (`auth_publickey` + `Auth::Reject { partial_success }`).
//! * stdio exec channel (`Handle::data` taking `impl Into<Bytes>`).
//! * SFTP subsystem (`SftpError -> StatusReply`) used by VS Code Remote.

use std::{sync::Arc, time::Duration};

use ed25519_dalek::SigningKey;
use embedded_ssh::{config::build_config, run_ssh_session};
use relay_control::signing::RelaySigningService;
use russh::{ChannelMsg, client, keys::PrivateKeyWithHashAlg};
use russh_sftp::{client::SftpSession, protocol::OpenFlags};
use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};

/// Build a russh client private key from a raw Ed25519 signing key, mirroring
/// how the server derives its host key in `config::build_config`.
fn client_private_key(signing_key: &SigningKey) -> russh::keys::PrivateKey {
let ed25519_private = Ed25519PrivateKey::from_bytes(&signing_key.to_bytes());
let keypair = Ed25519Keypair::from(ed25519_private);
russh::keys::PrivateKey::new(KeypairData::Ed25519(keypair), "").expect("valid Ed25519 key")
}

struct TestClient;

impl client::Handler for TestClient {
type Error = anyhow::Error;

async fn check_server_key(
&mut self,
_server_public_key: &ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
// Loopback test server; accept any host key.
Ok(true)
}
}

/// Spin up the embedded SSH server on a loopback socket with one authorized
/// client key, returning the bound address and a connected, authenticated
/// client session.
async fn connect_authenticated() -> (SigningKey, client::Handle<TestClient>) {
// Server host identity (any Ed25519 key works).
let host_signing = SigningKey::from_bytes(&[7u8; 32]);
// Client identity that we will authorize on the server.
let client_signing = SigningKey::from_bytes(&[42u8; 32]);

let relay_signing = RelaySigningService::new(SigningKey::from_bytes(&[1u8; 32]));
// Authorize the client's public key by registering an active signing session.
relay_signing
.create_session(client_signing.verifying_key())
.await;

let config = build_config(&host_signing);

let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback");
let addr = listener.local_addr().expect("local addr");

// Accept exactly one connection and drive an SSH session over it.
let server_relay = relay_signing.clone();
tokio::spawn(async move {
let (stream, _peer) = listener.accept().await.expect("accept");
let _ = run_ssh_session(stream, config, server_relay).await;
});

let client_config = Arc::new(client::Config {
inactivity_timeout: Some(Duration::from_secs(10)),
..Default::default()
});
let mut session = client::connect(client_config, addr, TestClient)
.await
.expect("client connect");

let key = client_private_key(&client_signing);
let auth = session
.authenticate_publickey("workspace", PrivateKeyWithHashAlg::new(Arc::new(key), None))
.await
.expect("auth call");
assert!(auth.success(), "public-key authentication should succeed");

(client_signing, session)
}

#[tokio::test]
async fn exec_channel_roundtrips_stdout() {
let (_client_signing, session) = connect_authenticated().await;

let mut channel = session.channel_open_session().await.expect("open channel");
channel
.exec(true, "printf 'hello-ssh'")
.await
.expect("exec");

let mut stdout = Vec::new();
let mut exit_code = None;
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { ref data } => stdout.extend_from_slice(data),
ChannelMsg::ExitStatus { exit_status } => exit_code = Some(exit_status),
ChannelMsg::Eof | ChannelMsg::Close => {}
_ => {}
}
}

assert_eq!(exit_code, Some(0), "command should exit cleanly");
assert_eq!(
String::from_utf8_lossy(&stdout),
"hello-ssh",
"stdout should round-trip through the stdio exec channel"
);
}

#[tokio::test]
async fn rejects_unauthorized_public_key() {
// Server with NO authorized client sessions.
let host_signing = SigningKey::from_bytes(&[9u8; 32]);
let relay_signing = RelaySigningService::new(SigningKey::from_bytes(&[2u8; 32]));
let config = build_config(&host_signing);

let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback");
let addr = listener.local_addr().expect("local addr");

let server_relay = relay_signing.clone();
tokio::spawn(async move {
if let Ok((stream, _)) = listener.accept().await {
let _ = run_ssh_session(stream, config, server_relay).await;
}
});

let client_config = Arc::new(client::Config {
inactivity_timeout: Some(Duration::from_secs(10)),
..Default::default()
});
let mut session = client::connect(client_config, addr, TestClient)
.await
.expect("client connect");

let unauthorized = SigningKey::from_bytes(&[123u8; 32]);
let key = client_private_key(&unauthorized);
let auth = session
.authenticate_publickey("workspace", PrivateKeyWithHashAlg::new(Arc::new(key), None))
.await
.expect("auth call");

assert!(
!auth.success(),
"auth must be rejected when no signing session matches the key"
);
}

#[tokio::test]
async fn sftp_subsystem_round_trips_a_file() {
let (_client_signing, session) = connect_authenticated().await;

let channel = session.channel_open_session().await.expect("open channel");
channel
.request_subsystem(true, "sftp")
.await
.expect("request sftp subsystem");
let sftp = SftpSession::new(channel.into_stream())
.await
.expect("start sftp session");

let dir = std::env::temp_dir();
let path = dir
.join(format!("vibe_sftp_smoke_{}.txt", std::process::id()))
.to_string_lossy()
.into_owned();

let payload = b"sftp-roundtrip-via-russh-0.61";

let mut file = sftp
.open_with_flags(
&path,
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE | OpenFlags::READ,
)
.await
.expect("open file over sftp");
file.write_all(payload).await.expect("write over sftp");
file.flush().await.expect("flush over sftp");

file.rewind().await.expect("rewind");
let mut readback = Vec::new();
file.read_to_end(&mut readback).await.expect("read back");
assert_eq!(readback, payload, "sftp file contents should round-trip");

file.shutdown().await.expect("close file");
sftp.remove_file(&path).await.expect("cleanup sftp file");
}
4 changes: 4 additions & 0 deletions crates/executors/src/actions/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ impl Executable for ScriptRequest {

let (shell_cmd, shell_arg) = get_shell_command();
let mut command = Command::new(shell_cmd);
// Script content is passed verbatim to the shell's -c argument.
// Trust boundary: scripts originate from authenticated user sessions
// and run in isolated per-workspace directories. No untrusted input
// reaches this path.
command
.kill_on_drop(true)
.stdin(std::process::Stdio::null())
Expand Down
Loading