#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
use malwaredb_lzjd::{LZDict, Murmur3HashState};
use malwaredb_types::exec::pe32::EXE;
use malwaredb_types::utils::entropy_calc;
use std::fmt::{Debug, Formatter};
use std::io::Cursor;
use std::path::Path;
use anyhow::{bail, Context, Result};
use base64::engine::general_purpose;
use base64::Engine;
use cart_container::JsonMap;
use fuzzyhash::FuzzyHash;
use home::home_dir;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384, Sha512};
use tlsh_fixed::TlshBuilder;
use tracing::{error, warn};
use zeroize::{Zeroize, ZeroizeOnDrop};
const DOT_MDB_CLIENT_TOML: &str = ".mdb_client.toml";
pub const MDB_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
pub struct MdbClient {
pub url: String,
api_key: String,
}
impl MdbClient {
pub fn new(url: String, api_key: String) -> Self {
let mut url = url;
let url = if url.ends_with('/') {
url.pop();
url
} else {
url
};
Self { url, api_key }
}
fn client() -> reqwest::Result<reqwest::Client> {
reqwest::ClientBuilder::new().gzip(true).build()
}
pub async fn login(
url: String,
username: String,
password: String,
save: bool,
) -> Result<Self> {
let mut url = url;
let url = if url.ends_with('/') {
url.pop();
url
} else {
url
};
let api_request = malwaredb_api::GetAPIKeyRequest {
user: username,
password,
};
let res = MdbClient::client()?
.post(format!("{url}{}", malwaredb_api::USER_LOGIN_URL))
.json(&api_request)
.send()
.await?
.json::<malwaredb_api::GetAPIKeyResponse>()
.await?;
if let Some(key) = &res.key {
let client = MdbClient {
url,
api_key: key.clone(),
};
if save {
if let Err(e) = client.save() {
error!("Login successful but failed to save config: {e}");
bail!("Login successful but failed to save config: {e}");
}
}
Ok(client)
} else {
if let Some(msg) = &res.message {
error!("Login failed, response: {msg}");
}
bail!("server error or bad credentials");
}
}
pub async fn reset_key(&self) -> Result<()> {
let response = MdbClient::client()?
.get(format!("{}{}", self.url, malwaredb_api::USER_LOGOUT_URL))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.send()
.await
.context("server error, or invalid API key")?;
if response.status().is_success() {
bail!("failed to reset API key, was it correct?");
}
Ok(())
}
pub fn from_file(path: &std::path::PathBuf) -> Result<Self> {
let config = std::fs::read_to_string(path)
.context(format!("failed to read config file {}", path.display()))?;
let cfg: MdbClient = toml::from_str(&config)
.context(format!("failed to parse config file {}", path.display()))?;
Ok(cfg)
}
pub fn load() -> Result<Self> {
let config = Path::new("mdb_client.toml");
if config.exists() {
return Self::from_file(&config.to_path_buf());
}
if let Some(mut home_config) = home_dir() {
home_config.push(DOT_MDB_CLIENT_TOML);
if home_config.exists() {
return Self::from_file(&home_config);
}
}
bail!("config file not found")
}
pub fn save(&self) -> Result<()> {
let toml = toml::to_string(self)?;
if let Some(mut home_config) = home_dir() {
home_config.push(DOT_MDB_CLIENT_TOML);
std::fs::write(&home_config, toml).context(format!(
"Unable to write config file at {}",
&home_config.display()
))?;
return Ok(());
}
std::fs::write("mdb_client.toml", toml).context("failed to write mdb config")
}
pub fn delete(&self) -> Result<()> {
if let Some(mut home_config) = home_dir() {
home_config.push(DOT_MDB_CLIENT_TOML);
if home_config.exists() {
std::fs::remove_file(home_config)?;
}
}
Ok(())
}
pub async fn server_info(&self) -> Result<malwaredb_api::ServerInfo> {
MdbClient::client()?
.get(format!("{}{}", self.url, malwaredb_api::SERVER_INFO))
.send()
.await?
.json::<malwaredb_api::ServerInfo>()
.await
.context("failed to receive or decode server info")
}
pub async fn supported_types(&self) -> Result<malwaredb_api::SupportedFileTypes> {
MdbClient::client()?
.get(format!(
"{}{}",
self.url,
malwaredb_api::SUPPORTED_FILE_TYPES
))
.send()
.await?
.json::<malwaredb_api::SupportedFileTypes>()
.await
.context("failed to receive or decode server-supported file types")
}
pub async fn whoami(&self) -> Result<malwaredb_api::GetUserInfoResponse> {
MdbClient::client()?
.get(format!("{}{}", self.url, malwaredb_api::USER_INFO_URL))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.send()
.await?
.json::<malwaredb_api::GetUserInfoResponse>()
.await
.context("failed to receive or decode user info, or invalid API key")
}
pub async fn labels(&self) -> Result<malwaredb_api::Labels> {
MdbClient::client()?
.get(format!("{}{}", self.url, malwaredb_api::LIST_LABELS))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.send()
.await?
.json::<malwaredb_api::Labels>()
.await
.context("failed to receive or decode available labels, or invalid API key")
}
pub async fn sources(&self) -> Result<malwaredb_api::Sources> {
MdbClient::client()?
.get(format!("{}{}", self.url, malwaredb_api::LIST_SOURCES))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.send()
.await?
.json::<malwaredb_api::Sources>()
.await
.context("failed to receive or decode available labels, or invalid API key")
}
pub async fn submit(
&self,
contents: impl AsRef<[u8]>,
file_name: &str,
source_id: u32,
) -> Result<bool> {
let mut hasher = Sha256::new();
hasher.update(&contents);
let result = hasher.finalize();
let encoded = general_purpose::STANDARD.encode(contents);
let payload = malwaredb_api::NewSample {
file_name: file_name.to_string(),
source_id,
file_contents_b64: encoded,
sha256: hex::encode(result),
};
match MdbClient::client()?
.post(format!("{}{}", self.url, malwaredb_api::UPLOAD_SAMPLE))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.json(&payload)
.send()
.await
{
Ok(res) => {
if !res.status().is_success() {
warn!("Code {} sending {file_name}", res.status());
}
Ok(res.status().is_success())
}
Err(e) => {
let status: String = e
.status()
.map(|s| s.as_str().to_string())
.unwrap_or_default();
error!("Error{status} sending {file_name}: {e}");
bail!(e.to_string())
}
}
}
pub async fn retrieve(&self, hash: &str, cart: bool) -> Result<Vec<u8>> {
let api_endpoint = if cart {
format!("{}{hash}", malwaredb_api::DOWNLOAD_SAMPLE_CART)
} else {
format!("{}{hash}", malwaredb_api::DOWNLOAD_SAMPLE)
};
let res = MdbClient::client()?
.get(format!("{}{api_endpoint}", self.url))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.send()
.await?;
if !res.status().is_success() {
bail!("Received code {}", res.status());
}
let body = res.bytes().await?;
Ok(body.to_vec())
}
pub async fn report(&self, hash: &str) -> Result<malwaredb_api::Report> {
MdbClient::client()?
.get(format!(
"{}{}/{hash}",
self.url,
malwaredb_api::SAMPLE_REPORT
))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.send()
.await?
.json::<malwaredb_api::Report>()
.await
.context("failed to receive or decode sample report, or invalid API key")
}
pub async fn similar(&self, contents: &[u8]) -> Result<malwaredb_api::SimilarSamplesResponse> {
let mut hashes = vec![];
let ssdeep_hash = FuzzyHash::new(contents);
let build_hasher = Murmur3HashState::default();
let lzjd_str =
LZDict::from_bytes_stream(contents.iter().copied(), &build_hasher).to_string();
hashes.push((malwaredb_api::SimilarityHashType::LZJD, lzjd_str));
hashes.push((
malwaredb_api::SimilarityHashType::SSDeep,
ssdeep_hash.to_string(),
));
let mut builder = TlshBuilder::new(
tlsh_fixed::BucketKind::Bucket256,
tlsh_fixed::ChecksumKind::ThreeByte,
tlsh_fixed::Version::Version4,
);
builder.update(contents);
if let Ok(hasher) = builder.build() {
hashes.push((malwaredb_api::SimilarityHashType::TLSH, hasher.hash()));
}
if let Ok(exe) = EXE::from(contents) {
if let Some(imports) = exe.imports {
hashes.push((
malwaredb_api::SimilarityHashType::ImportHash,
hex::encode(imports.hash()),
));
hashes.push((
malwaredb_api::SimilarityHashType::FuzzyImportHash,
imports.fuzzy_hash(),
));
}
}
let request = malwaredb_api::SimilarSamplesRequest { hashes };
MdbClient::client()?
.post(format!("{}{}", self.url, malwaredb_api::SIMILAR_SAMPLES))
.header(malwaredb_api::MDB_API_HEADER, &self.api_key)
.json(&request)
.send()
.await?
.json::<malwaredb_api::SimilarSamplesResponse>()
.await
.context("failed to receive or decode similarity response, or invalid API key")
}
}
impl Debug for MdbClient {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "MDB Client v{MDB_VERSION}: {}", self.url)
}
}
pub fn encode_to_cart(data: &[u8]) -> Result<Vec<u8>> {
let mut input_buffer = Cursor::new(data);
let mut output_buffer = Cursor::new(vec![]);
let mut output_metadata = JsonMap::new();
let mut sha384 = Sha384::new();
sha384.update(data);
let sha384 = hex::encode(sha384.finalize());
let mut sha512 = Sha512::new();
sha512.update(data);
let sha512 = hex::encode(sha512.finalize());
output_metadata.insert("sha384".into(), sha384.into());
output_metadata.insert("sha512".into(), sha512.into());
output_metadata.insert("entropy".into(), entropy_calc(data).into());
cart_container::pack_stream(
&mut input_buffer,
&mut output_buffer,
Some(output_metadata),
None,
cart_container::digesters::default_digesters(),
None,
)?;
Ok(output_buffer.into_inner())
}
pub fn decode_from_cart(data: &[u8]) -> Result<(Vec<u8>, Option<JsonMap>, Option<JsonMap>)> {
let mut input_buffer = Cursor::new(data);
let mut output_buffer = Cursor::new(vec![]);
let (header, footer) =
cart_container::unpack_stream(&mut input_buffer, &mut output_buffer, None)?;
Ok((output_buffer.into_inner(), header, footer))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cart() {
const BYTES: &[u8] = include_bytes!("../../crates/types/testdata/elf/elf_haiku_x86.cart");
const ORIGINAL_SHA256: &str =
"de10ba5e5402b46ea975b5cb8a45eb7df9e81dc81012fd4efd145ed2dce3a740";
let (decoded, header, footer) = decode_from_cart(BYTES).unwrap();
let mut sha256 = Sha256::new();
sha256.update(&decoded);
let sha256 = hex::encode(sha256.finalize());
assert_eq!(sha256, ORIGINAL_SHA256);
let header = header.unwrap();
let entropy = header.get("entropy").unwrap().as_f64().unwrap();
assert!(entropy > 4.0 && entropy < 4.1);
let footer = footer.unwrap();
assert_eq!(footer.get("length").unwrap(), "5093");
assert_eq!(footer.get("sha256").unwrap(), ORIGINAL_SHA256);
}
}