Skip to content

Commit 0116616

Browse files
authored
Merge pull request #86 from vsilent/fix/security-owasp
OWASP-10 security fixes
2 parents cdda91e + d127230 commit 0116616

12 files changed

Lines changed: 1364 additions & 149 deletions

File tree

src/agent/daemon.rs

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use tracing::{error, info, warn};
1111
use crate::agent::config::Config;
1212
use crate::commands::executor::CommandExecutor;
1313
use crate::commands::firewall::FirewallPolicy;
14+
use crate::commands::validator::CommandValidator;
1415
use crate::commands::TimeoutStrategy;
1516
use crate::monitoring::{spawn_heartbeat, MetricsCollector, MetricsSnapshot, MetricsStore};
1617
use crate::transport::{http_polling, CommandResult};
@@ -216,36 +217,54 @@ async fn execute_and_report(
216217
}
217218
}
218219
Ok(None) => {
219-
// Not a stacker command, fall back to shell execution
220-
info!(
221-
command_id = %cmd.command_id,
222-
command_name = %cmd.name,
223-
"executing as shell command"
224-
);
225-
let strategy = TimeoutStrategy::backup_strategy(ctx.command_timeout);
226-
let exec_result = executor.execute(&cmd, strategy).await;
227-
228-
match exec_result {
229-
Ok(output) => CommandResult {
230-
command_id: cmd.command_id.clone(),
231-
status: "success".to_string(),
232-
result: Some(json!({
233-
"stdout": output.stdout,
234-
"stderr": output.stderr,
235-
"exit_code": output.exit_code,
236-
})),
237-
error: None,
238-
completed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
239-
..CommandResult::default()
240-
},
241-
Err(e) => CommandResult {
220+
// Not a stacker command — validate before shell execution
221+
let validator = CommandValidator::default_secure();
222+
if let Err(e) = validator.validate(&cmd) {
223+
error!(
224+
command_id = %cmd.command_id,
225+
command_name = %cmd.name,
226+
error = %e,
227+
"shell command rejected by validator"
228+
);
229+
CommandResult {
242230
command_id: cmd.command_id.clone(),
243231
status: "failed".to_string(),
244232
result: None,
245-
error: Some(e.to_string()),
233+
error: Some(format!("Command validation failed: {}", e)),
246234
completed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
247235
..CommandResult::default()
248-
},
236+
}
237+
} else {
238+
info!(
239+
command_id = %cmd.command_id,
240+
command_name = %cmd.name,
241+
"executing validated shell command"
242+
);
243+
let strategy = TimeoutStrategy::backup_strategy(ctx.command_timeout);
244+
let exec_result = executor.execute(&cmd, strategy).await;
245+
246+
match exec_result {
247+
Ok(output) => CommandResult {
248+
command_id: cmd.command_id.clone(),
249+
status: "success".to_string(),
250+
result: Some(json!({
251+
"stdout": output.stdout,
252+
"stderr": output.stderr,
253+
"exit_code": output.exit_code,
254+
})),
255+
error: None,
256+
completed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
257+
..CommandResult::default()
258+
},
259+
Err(e) => CommandResult {
260+
command_id: cmd.command_id.clone(),
261+
status: "failed".to_string(),
262+
result: None,
263+
error: Some(e.to_string()),
264+
completed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
265+
..CommandResult::default()
266+
},
267+
}
249268
}
250269
}
251270
Err(e) => {

src/agent/docker.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,65 @@ pub async fn exec_in_container(name: &str, cmd: &str) -> Result<()> {
699699
}
700700
}
701701

702+
/// Execute a command inside a container using an explicit argv (no shell interpretation).
703+
/// Each element in `argv` is passed directly to execve, preventing shell injection.
704+
pub async fn exec_in_container_argv(name: &str, argv: Vec<String>) -> Result<()> {
705+
use bollard::exec::StartExecResults;
706+
use futures_util::StreamExt;
707+
708+
let docker = docker_client()?;
709+
let resolved_name = resolve_container_name(name)
710+
.await
711+
.unwrap_or_else(|_| name.to_string());
712+
let argv_display = argv.join(" ");
713+
let exec = docker
714+
.create_exec(
715+
&resolved_name,
716+
CreateExecOptions {
717+
attach_stdout: Some(true),
718+
attach_stderr: Some(true),
719+
tty: Some(false),
720+
cmd: Some(argv),
721+
..Default::default()
722+
},
723+
)
724+
.await
725+
.context("create exec")?;
726+
727+
let start = docker
728+
.start_exec(&exec.id, None)
729+
.await
730+
.context("start exec")?;
731+
732+
let mut combined = String::new();
733+
match start {
734+
StartExecResults::Detached => {
735+
debug!(container = name, command = %argv_display, "exec_argv detached");
736+
}
737+
StartExecResults::Attached { mut output, .. } => {
738+
while let Some(item) = output.next().await {
739+
match item {
740+
Ok(log) => combined.push_str(&format!("{}", log)),
741+
Err(e) => error!("exec output stream error: {}", e),
742+
}
743+
}
744+
}
745+
}
746+
747+
let info = docker
748+
.inspect_exec(&exec.id)
749+
.await
750+
.context("inspect exec")?;
751+
let exit_code = info.exit_code.unwrap_or_default();
752+
if exit_code == 0 {
753+
debug!(container = name, command = %argv_display, "exec_argv completed");
754+
Ok(())
755+
} else {
756+
error!(container = name, command = %argv_display, exit_code, output = combined, "exec_argv failed");
757+
Err(anyhow::anyhow!("exec failed with code {}", exit_code))
758+
}
759+
}
760+
702761
/// Execute a shell command inside a running container and return output.
703762
/// Returns (exit_code, stdout, stderr) tuple.
704763
pub async fn exec_in_container_with_output(name: &str, cmd: &str) -> Result<(i64, String, String)> {

src/commands/self_update.rs

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ pub async fn start_update_job(jobs: UpdateJobs, target_version: Option<String>)
7474
return;
7575
};
7676

77+
// Enforce HTTPS for update URLs using shared validation policy
78+
if !crate::security::validation::is_safe_update_url(&url) {
79+
let mut w = jobs_clone.write().await;
80+
if let Some(st) = w.get_mut(&id_clone) {
81+
st.phase =
82+
UpdatePhase::Failed("Update URL must use HTTPS for integrity".to_string());
83+
}
84+
return;
85+
}
86+
7787
{
7888
let mut w = jobs_clone.write().await;
7989
if let Some(st) = w.get_mut(&id_clone) {
@@ -114,29 +124,38 @@ pub async fn start_update_job(jobs: UpdateJobs, target_version: Option<String>)
114124
}
115125
}
116126

117-
// Optional SHA256 verification
118-
if let Some(expected) = expected_sha {
119-
let verify_res = async {
120-
let data = tokio::fs::read(&tmp_path)
121-
.await
122-
.context("reading temp binary for sha256")?;
123-
let mut hasher = Sha256::new();
124-
hasher.update(&data);
125-
let got = format!("{:x}", hasher.finalize());
127+
// SHA256 verification — mandatory for update integrity
128+
let verify_res = async {
129+
let data = tokio::fs::read(&tmp_path)
130+
.await
131+
.context("reading temp binary for sha256")?;
132+
let mut hasher = Sha256::new();
133+
hasher.update(&data);
134+
let got = format!("{:x}", hasher.finalize());
135+
if let Some(expected) = &expected_sha {
126136
if got != expected.to_lowercase() {
127137
anyhow::bail!("sha256 mismatch: got {} expected {}", got, expected);
128138
}
129-
Result::<()>::Ok(())
139+
} else {
140+
tracing::error!(
141+
sha256 = %got,
142+
"UPDATE_EXPECTED_SHA256 not set — refusing update because integrity \
143+
verification is mandatory"
144+
);
145+
anyhow::bail!(
146+
"UPDATE_EXPECTED_SHA256 not set — integrity verification is mandatory"
147+
);
130148
}
131-
.await;
149+
Result::<()>::Ok(())
150+
}
151+
.await;
132152

133-
if let Err(e) = verify_res {
134-
let mut w = jobs_clone.write().await;
135-
if let Some(st) = w.get_mut(&id_clone) {
136-
st.phase = UpdatePhase::Failed(e.to_string());
137-
}
138-
return;
153+
if let Err(e) = verify_res {
154+
let mut w = jobs_clone.write().await;
155+
if let Some(st) = w.get_mut(&id_clone) {
156+
st.phase = UpdatePhase::Failed(e.to_string());
139157
}
158+
return;
140159
}
141160

142161
// Completed preparation (download + verify). Deployment handled in a later phase.

0 commit comments

Comments
 (0)