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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
**Vulnerability:** The CLI application creates sensitive configuration files and directories (like wallets and snapshot data) using standard `fs::create_dir_all` and `fs::write` in Rust. These standard functions create files/directories using the system's default umask, which typically allows other users on the same Unix-like system to read the sensitive files.
**Learning:** This could lead to a local privilege escalation or exposure of sensitive user data if the user runs the CLI on a shared machine. Relying on default system configurations for sensitive files is unsafe.
**Prevention:** Always use `std::os::unix::fs::DirBuilderExt` and `std::os::unix::fs::OpenOptionsExt` to explicitly set file permissions (e.g., `0o700` for directories and `0o600` for files) when creating sensitive data on disk.

## 2024-05-23 - Arbitrary Command Execution via Configuration Profiles
**Vulnerability:** The application executes `bitcoin-cli` using a user-provided configuration path (`profile.bitcoin_cli`) directly in `std::process::Command::new`, without verifying the actual executable name.
**Learning:** This allows an attacker who gains write access to the `.zinc-cli/profiles/*.json` configuration files to set `bitcoin_cli` to any arbitrary malicious script or binary on the system (e.g., `rm -rf /` or `/bin/sh`). When the app subsequently attempts to use `bitcoin-cli`, it executes the malicious payload instead.
**Prevention:** Always validate and restrict binary names executed via external commands, even when the path is loaded from a "trusted" local configuration file. Centralize command execution logic and enforce a strict whitelist of allowed binary filenames (e.g., exactly `bitcoin-cli` or `bitcoin-cli.exe`).
17 changes: 7 additions & 10 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ pub enum PolicyMode {
Strict,
}



#[derive(Parser, Debug, Clone)]
#[command(
name = "zinc-cli",
Expand All @@ -21,8 +19,11 @@ pub struct Cli {
#[command(subcommand)]
pub command: Command,


#[arg(long, global = true, help = "Agent mode (machine-readable JSON output)")]
#[arg(
long,
global = true,
help = "Agent mode (machine-readable JSON output)"
)]
pub agent: bool,

#[arg(long, global = true, help = "Suppress all non-error output")]
Expand Down Expand Up @@ -118,11 +119,7 @@ pub struct Cli {
)]
pub network_retries: u32,

#[arg(
long,
global = true,
help = "Disable inscription thumbnails"
)]
#[arg(long, global = true, help = "Disable inscription thumbnails")]
pub no_thumb: bool,

#[arg(
Expand Down Expand Up @@ -195,7 +192,7 @@ pub struct SetupArgs {
pub default_ord_url: Option<String>,
#[arg(long)]
pub quiet_default: Option<bool>,

#[arg(long)]
pub restore_mnemonic: Option<String>,
#[arg(long)]
Expand Down
2 changes: 1 addition & 1 deletion src/commands/account.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::cli::{AccountAction, AccountArgs, Cli};
use crate::error::AppError;
use crate::output::CommandOutput;
use crate::wallet_service::{now_unix, AccountState};
use crate::{load_wallet_session, profile_path, read_profile, write_profile};
use crate::output::CommandOutput;
use zinc_core::Account;

pub async fn run(cli: &Cli, args: &AccountArgs) -> Result<CommandOutput, AppError> {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/address.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::cli::{AddressArgs, AddressKind, Cli};
use crate::error::AppError;
use crate::output::CommandOutput;
use crate::wallet_service::map_wallet_error;
use crate::{load_wallet_session, persist_wallet_session};
use crate::output::CommandOutput;

pub async fn run(cli: &Cli, args: &AddressArgs) -> Result<CommandOutput, AppError> {
let mut session = load_wallet_session(cli)?;
Expand Down
7 changes: 6 additions & 1 deletion src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result<CommandOutput, AppError
save_persisted_config(&config)?;
Ok(CommandOutput::ConfigSet {
key: field.as_str().to_string(),
value: applied.as_str().unwrap_or("").to_string().replace("\"", "").to_string(),
value: applied
.as_str()
.unwrap_or("")
.to_string()
.replace("\"", "")
.to_string(),
saved: true,
})
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::cli::Cli;
use crate::error::AppError;
use crate::{profile_path, read_profile};
use crate::output::CommandOutput;
use crate::{profile_path, read_profile};
use zinc_core::{OrdClient, ZincWallet};

pub async fn run(cli: &Cli) -> Result<CommandOutput, AppError> {
Expand Down
6 changes: 1 addition & 5 deletions src/commands/inscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ pub async fn run(cli: &Cli, _args: &InscriptionArgs) -> Result<CommandOutput, Ap
let display_items = if !cli.thumb_enabled() {
None
} else {
Some(get_inscription_display_items(
&session.profile.ord_url,
&sorted_inscriptions,
)
.await)
Some(get_inscription_display_items(&session.profile.ord_url, &sorted_inscriptions).await)
};

Ok(CommandOutput::InscriptionList {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/lock.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::cli::{Cli, LockAction, LockArgs};
use crate::error::AppError;
use crate::output::CommandOutput;
use crate::wallet_service::now_unix;
use crate::{confirm, profile_lock_path, read_lock_metadata};
use crate::output::CommandOutput;
use std::fs;

pub async fn run(cli: &Cli, args: &LockArgs) -> Result<CommandOutput, AppError> {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ pub mod setup;
pub mod snapshot;
pub mod sync;
pub mod tx;
pub mod version;
pub mod wait;
pub mod wallet;
pub mod version;
207 changes: 135 additions & 72 deletions src/commands/offer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ use crate::commands::psbt::{analyze_psbt_with_policy, enforce_policy_mode};
use crate::config::NetworkArg;
use crate::error::AppError;
use crate::network_retry::with_network_retry;
use crate::presenter::thumbnail::{render_non_image_badge, print_thumbnail};
use crate::output::CommandOutput;
use crate::presenter::thumbnail::{print_thumbnail, render_non_image_badge};
use crate::utils::{maybe_write_text, resolve_psbt_source};
use crate::wallet_service::map_wallet_error;
use crate::{load_wallet_session, persist_wallet_session};
use crate::output::CommandOutput;
use serde_json::{json, Value};
use std::io::Read;
use std::time::{SystemTime, UNIX_EPOCH};
Expand Down Expand Up @@ -345,66 +345,132 @@ async fn finalize_offer_output(
let hide_inscription_ids = cli.thumb_enabled();

match action {
OfferAction::Create { inscription, .. } => {
Ok(CommandOutput::OfferCreate {
inscription: inscription.clone(),
ask_sats: response.get("ask_sats").and_then(Value::as_u64).unwrap_or(0),
fee_rate_sat_vb: response.get("fee_rate_sat_vb").and_then(Value::as_u64).unwrap_or(0),
seller_address: response.get("seller_address").and_then(Value::as_str).unwrap_or("").to_string(),
seller_outpoint: response.get("seller_outpoint").and_then(Value::as_str).unwrap_or("").to_string(),
seller_pubkey_hex: response.get("offer").and_then(|o| o.get("seller_pubkey_hex")).and_then(Value::as_str).unwrap_or("").to_string(),
expires_at_unix: response.get("offer").and_then(|o| o.get("expires_at_unix")).and_then(Value::as_i64).unwrap_or(0),
thumbnail_lines,
hide_inscription_ids,
raw_response: response,
})
}
OfferAction::Publish { .. } => {
Ok(CommandOutput::OfferPublish {
event_id: response.get("event").and_then(|v| v.get("id")).and_then(Value::as_str).unwrap_or("").to_string(),
accepted_relays: response.get("accepted_relays").and_then(Value::as_u64).unwrap_or(0),
total_relays: response.get("total_relays").and_then(Value::as_u64).unwrap_or(0),
publish_results: response.get("publish_results").and_then(Value::as_array).unwrap_or(&vec![]).clone(),
raw_response: response,
})
}
OfferAction::Discover { .. } => {
Ok(CommandOutput::OfferDiscover {
event_count: response.get("event_count").and_then(Value::as_u64).unwrap_or(0),
offer_count: response.get("offer_count").and_then(Value::as_u64).unwrap_or(0),
offers: response.get("offers").and_then(Value::as_array).unwrap_or(&vec![]).clone(),
thumbnail_lines,
hide_inscription_ids,
raw_response: response,
})
}
OfferAction::SubmitOrd { .. } => {
Ok(CommandOutput::OfferSubmitOrd {
ord_url: response.get("ord_url").and_then(Value::as_str).unwrap_or("").to_string(),
submitted: true,
raw_response: response,
})
}
OfferAction::ListOrd => {
Ok(CommandOutput::OfferListOrd {
ord_url: response.get("ord_url").and_then(Value::as_str).unwrap_or("").to_string(),
count: response.get("count").and_then(Value::as_u64).unwrap_or(0),
offers: response.get("offers").and_then(Value::as_array).unwrap_or(&vec![]).clone(),
raw_response: response,
})
}
OfferAction::Accept { .. } => {
Ok(CommandOutput::OfferAccept {
inscription: response.get("inscription_id").and_then(Value::as_str).unwrap_or("").to_string(),
ask_sats: response.get("ask_sats").and_then(Value::as_u64).unwrap_or(0),
txid: response.get("txid").and_then(Value::as_str).unwrap_or("-").to_string(),
dry_run: response.get("dry_run").and_then(Value::as_bool).unwrap_or(false),
inscription_risk: response.get("inscription_risk").and_then(Value::as_str).unwrap_or("").to_string(),
thumbnail_lines,
hide_inscription_ids,
raw_response: response,
})
}
OfferAction::Create { inscription, .. } => Ok(CommandOutput::OfferCreate {
inscription: inscription.clone(),
ask_sats: response
.get("ask_sats")
.and_then(Value::as_u64)
.unwrap_or(0),
fee_rate_sat_vb: response
.get("fee_rate_sat_vb")
.and_then(Value::as_u64)
.unwrap_or(0),
seller_address: response
.get("seller_address")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
seller_outpoint: response
.get("seller_outpoint")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
seller_pubkey_hex: response
.get("offer")
.and_then(|o| o.get("seller_pubkey_hex"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
expires_at_unix: response
.get("offer")
.and_then(|o| o.get("expires_at_unix"))
.and_then(Value::as_i64)
.unwrap_or(0),
thumbnail_lines,
hide_inscription_ids,
raw_response: response,
}),
OfferAction::Publish { .. } => Ok(CommandOutput::OfferPublish {
event_id: response
.get("event")
.and_then(|v| v.get("id"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
accepted_relays: response
.get("accepted_relays")
.and_then(Value::as_u64)
.unwrap_or(0),
total_relays: response
.get("total_relays")
.and_then(Value::as_u64)
.unwrap_or(0),
publish_results: response
.get("publish_results")
.and_then(Value::as_array)
.unwrap_or(&vec![])
.clone(),
raw_response: response,
}),
OfferAction::Discover { .. } => Ok(CommandOutput::OfferDiscover {
event_count: response
.get("event_count")
.and_then(Value::as_u64)
.unwrap_or(0),
offer_count: response
.get("offer_count")
.and_then(Value::as_u64)
.unwrap_or(0),
offers: response
.get("offers")
.and_then(Value::as_array)
.unwrap_or(&vec![])
.clone(),
thumbnail_lines,
hide_inscription_ids,
raw_response: response,
}),
OfferAction::SubmitOrd { .. } => Ok(CommandOutput::OfferSubmitOrd {
ord_url: response
.get("ord_url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
submitted: true,
raw_response: response,
}),
OfferAction::ListOrd => Ok(CommandOutput::OfferListOrd {
ord_url: response
.get("ord_url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
count: response.get("count").and_then(Value::as_u64).unwrap_or(0),
offers: response
.get("offers")
.and_then(Value::as_array)
.unwrap_or(&vec![])
.clone(),
raw_response: response,
}),
OfferAction::Accept { .. } => Ok(CommandOutput::OfferAccept {
inscription: response
.get("inscription_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
ask_sats: response
.get("ask_sats")
.and_then(Value::as_u64)
.unwrap_or(0),
txid: response
.get("txid")
.and_then(Value::as_str)
.unwrap_or("-")
.to_string(),
dry_run: response
.get("dry_run")
.and_then(Value::as_bool)
.unwrap_or(false),
inscription_risk: response
.get("inscription_risk")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
thumbnail_lines,
hide_inscription_ids,
raw_response: response,
}),
}
}

Expand All @@ -418,9 +484,12 @@ async fn maybe_offer_thumbnail_lines(
}

let inscription_id = offer_thumbnail_inscription_id(action, response)?;
let ord_url = resolve_ord_url(cli)
.ok()
.or_else(|| response.get("ord_url").and_then(Value::as_str).map(ToString::to_string))?;
let ord_url = resolve_ord_url(cli).ok().or_else(|| {
response
.get("ord_url")
.and_then(Value::as_str)
.map(ToString::to_string)
})?;

let client = OrdClient::new(ord_url);
let details = client.get_inscription_details(&inscription_id).await.ok()?;
Expand Down Expand Up @@ -470,8 +539,6 @@ fn offer_thumbnail_inscription_id(action: &OfferAction, response: &Value) -> Opt
}
}



pub fn abbreviate(value: &str, prefix: usize, suffix: usize) -> String {
if value.chars().count() <= prefix + suffix + 3 {
return value.to_string();
Expand Down Expand Up @@ -614,9 +681,7 @@ fn map_offer_error<E: ToString>(err: E) -> AppError {

#[cfg(test)]
mod tests {
use super::{
abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source,
};
use super::{abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source};
use crate::error::AppError;
use zinc_core::OfferEnvelopeV1;

Expand Down Expand Up @@ -691,6 +756,4 @@ mod tests {
let short = abbreviate(value, 6, 4);
assert_eq!(short, "123456...cdef");
}


}
4 changes: 2 additions & 2 deletions src/commands/psbt.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use crate::cli::{Cli, PolicyMode, PsbtAction, PsbtArgs};
use crate::error::AppError;
use crate::network_retry::with_network_retry;
use crate::output::CommandOutput;
use crate::utils::{maybe_write_text, parse_indices, resolve_psbt_source};
use crate::wallet_service::map_wallet_error;
use crate::{load_wallet_session, persist_wallet_session};
use crate::output::CommandOutput;
use serde_json::{json, Value};
use zinc_core::*;

Expand Down Expand Up @@ -61,7 +61,7 @@ pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result<CommandOutput, AppError>
"safe_to_send": policy.safe_to_send,
"inscription_risk": policy.inscription_risk,
"reasons": policy.policy_reasons
})
}),
})
}
PsbtAction::Sign {
Expand Down
Loading
Loading