1use std::fmt::{Debug, Formatter};
4use std::path::{Path, PathBuf};
5
6use crate::get_config_path;
7use malwaredb_types::exec::pe32::EXE;
8
9use anyhow::{bail, ensure, Context, Result};
10use base64::engine::general_purpose;
11use base64::Engine;
12use fuzzyhash::FuzzyHash;
13use malwaredb_api::{PartialHashSearchType, SearchRequest};
14use malwaredb_lzjd::{LZDict, Murmur3HashState};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use tlsh_fixed::TlshBuilder;
18use tracing::{error, info, warn};
19use zeroize::{Zeroize, ZeroizeOnDrop};
20
21#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
23pub struct MdbClient {
24 pub url: String,
26
27 api_key: String,
29
30 #[zeroize(skip)]
32 #[serde(skip)]
33 client: reqwest::blocking::Client,
34}
35
36impl MdbClient {
37 pub fn new(url: String, api_key: String, cert_path: Option<PathBuf>) -> Result<Self> {
44 let mut url = url;
45 let url = if url.ends_with('/') {
46 url.pop();
47 url
48 } else {
49 url
50 };
51
52 let cert = if let Some(path) = cert_path {
53 Some((crate::path_load_cert(&path)?, path))
54 } else {
55 None
56 };
57
58 let builder = reqwest::blocking::ClientBuilder::new()
59 .gzip(true)
60 .zstd(true)
61 .use_rustls_tls()
62 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")));
63
64 let client = if let Some(cert) = cert {
65 builder.add_root_certificate(cert.0.clone()).build()
66 } else {
67 builder.build()
68 }?;
69
70 Ok(Self {
71 url,
72 api_key,
73 client,
74 })
75 }
76
77 pub fn login(
84 url: String,
85 username: String,
86 password: String,
87 save: bool,
88 cert_path: Option<PathBuf>,
89 ) -> Result<Self> {
90 let mut url = url;
91 let url = if url.ends_with('/') {
92 url.pop();
93 url
94 } else {
95 url
96 };
97
98 let api_request = malwaredb_api::GetAPIKeyRequest {
99 user: username,
100 password,
101 };
102
103 let builder = reqwest::blocking::ClientBuilder::new()
104 .gzip(true)
105 .zstd(true)
106 .use_rustls_tls()
107 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")));
108
109 let cert = if let Some(path) = cert_path {
110 Some((crate::path_load_cert(&path)?, path))
111 } else {
112 None
113 };
114
115 let client = if let Some(cert) = &cert {
116 builder.add_root_certificate(cert.0.clone()).build()
117 } else {
118 builder.build()
119 }?;
120
121 let res = client
122 .post(format!("{url}{}", malwaredb_api::USER_LOGIN_URL))
123 .json(&api_request)
124 .send()?
125 .json::<malwaredb_api::GetAPIKeyResponse>()?;
126
127 if let Some(key) = &res.key {
128 let client = MdbClient {
129 url,
130 api_key: key.clone(),
131 client,
132 };
133
134 let server_info = client.server_info()?;
135 if server_info.mdb_version > *crate::MDB_VERSION_SEMVER {
136 warn!(
137 "Server version {:?} is newer than client {:?}, consider updating.",
138 server_info.mdb_version,
139 crate::MDB_VERSION_SEMVER
140 );
141 }
142
143 if save {
144 if let Err(e) = client.save() {
145 error!("Login successful but failed to save config: {e}");
146 bail!("Login successful but failed to save config: {e}");
147 }
148 }
149 Ok(client)
150 } else {
151 if let Some(msg) = &res.message {
152 error!("Login failed, response: {msg}");
153 }
154 bail!("server error or bad credentials");
155 }
156 }
157
158 pub fn reset_key(&self) -> Result<()> {
164 let response = self
165 .client
166 .get(format!("{}{}", self.url, malwaredb_api::USER_LOGOUT_URL))
167 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
168 .send()
169 .context("server error, or invalid API key")?;
170 if !response.status().is_success() {
171 bail!("failed to reset API key, was it correct?");
172 }
173 Ok(())
174 }
175
176 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
183 let name = path.as_ref().display();
184 let config =
185 std::fs::read_to_string(&path).context(format!("failed to read config file {name}"))?;
186 let cfg: MdbClient =
187 toml::from_str(&config).context(format!("failed to parse config file {name}"))?;
188 Ok(cfg)
189 }
190
191 pub fn load() -> Result<Self> {
198 let path = get_config_path(false)?;
199 if path.exists() {
200 return Self::from_file(path);
201 }
202 bail!("config file not found")
203 }
204
205 pub fn save(&self) -> Result<()> {
211 let toml = toml::to_string(self)?;
212 let path = get_config_path(true)?;
213 std::fs::write(&path, toml)
214 .context(format!("failed to write mdb config to {}", path.display()))
215 }
216
217 pub fn delete(&self) -> Result<()> {
224 let path = get_config_path(false)?;
225 if path.exists() {
226 std::fs::remove_file(&path).context(format!(
227 "failed to delete client config file {}",
228 path.display()
229 ))?;
230 }
231 Ok(())
232 }
233
234 pub fn server_info(&self) -> Result<malwaredb_api::ServerInfo> {
242 self.client
243 .get(format!("{}{}", self.url, malwaredb_api::SERVER_INFO))
244 .send()?
245 .json::<malwaredb_api::ServerInfo>()
246 .context("failed to receive or decode server info")
247 }
248
249 pub fn supported_types(&self) -> Result<malwaredb_api::SupportedFileTypes> {
255 self.client
256 .get(format!(
257 "{}{}",
258 self.url,
259 malwaredb_api::SUPPORTED_FILE_TYPES
260 ))
261 .send()?
262 .json::<malwaredb_api::SupportedFileTypes>()
263 .context("failed to receive or decode server-supported file types")
264 }
265
266 pub fn whoami(&self) -> Result<malwaredb_api::GetUserInfoResponse> {
273 self.client
274 .get(format!("{}{}", self.url, malwaredb_api::USER_INFO_URL))
275 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
276 .send()?
277 .json::<malwaredb_api::GetUserInfoResponse>()
278 .context("failed to receive or decode user info, or invalid API key")
279 }
280
281 pub fn labels(&self) -> Result<malwaredb_api::Labels> {
288 self.client
289 .get(format!("{}{}", self.url, malwaredb_api::LIST_LABELS))
290 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
291 .send()?
292 .json::<malwaredb_api::Labels>()
293 .context("failed to receive or decode available labels, or invalid API key")
294 }
295
296 pub fn sources(&self) -> Result<malwaredb_api::Sources> {
303 self.client
304 .get(format!("{}{}", self.url, malwaredb_api::LIST_SOURCES))
305 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
306 .send()?
307 .json::<malwaredb_api::Sources>()
308 .context("failed to receive or decode available labels, or invalid API key")
309 }
310
311 pub fn submit(
318 &self,
319 contents: impl AsRef<[u8]>,
320 file_name: String,
321 source_id: u32,
322 ) -> Result<bool> {
323 let mut hasher = Sha256::new();
324 hasher.update(&contents);
325 let result = hasher.finalize();
326
327 let encoded = general_purpose::STANDARD.encode(contents);
328
329 let payload = malwaredb_api::NewSample {
330 file_name,
331 source_id,
332 file_contents_b64: encoded,
333 sha256: hex::encode(result),
334 };
335
336 match self
337 .client
338 .post(format!("{}{}", self.url, malwaredb_api::UPLOAD_SAMPLE))
339 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
340 .json(&payload)
341 .send()
342 {
343 Ok(res) => {
344 if !res.status().is_success() {
345 info!("Code {} sending {}", res.status(), payload.file_name);
346 }
347 Ok(res.status().is_success())
348 }
349 Err(e) => {
350 let status: String = e
351 .status()
352 .map(|s| s.as_str().to_string())
353 .unwrap_or_default();
354 error!("Error{status} sending {}: {e}", payload.file_name);
355 bail!(e.to_string())
356 }
357 }
358 }
359
360 pub fn partial_search(
366 &self,
367 partial_hash: Option<(PartialHashSearchType, String)>,
368 name: Option<String>,
369 response: PartialHashSearchType,
370 limit: u32,
371 ) -> Result<Vec<String>> {
372 let query = SearchRequest {
373 partial_hash,
374 file_name: name,
375 response,
376 limit,
377 };
378
379 ensure!(
380 query.is_valid(),
381 "Query isn't valid: hash isn't hexidecimal or both the hashes and file name are empty"
382 );
383
384 self.client
385 .post(format!("{}{}", self.url, malwaredb_api::SEARCH))
386 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
387 .json(&query)
388 .send()?
389 .json::<Vec<String>>()
390 .context("failed to receive or decode hash list, or invalid API key")
391 }
392
393 pub fn retrieve(&self, hash: &str, cart: bool) -> Result<Vec<u8>> {
400 let api_endpoint = if cart {
401 format!("{}{hash}", malwaredb_api::DOWNLOAD_SAMPLE_CART)
402 } else {
403 format!("{}{hash}", malwaredb_api::DOWNLOAD_SAMPLE)
404 };
405
406 let res = self
407 .client
408 .get(format!("{}{api_endpoint}", self.url))
409 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
410 .send()?;
411
412 if !res.status().is_success() {
413 bail!("Received code {}", res.status());
414 }
415
416 let body = res.bytes()?;
417 Ok(body.to_vec())
418 }
419
420 pub fn report(&self, hash: &str) -> Result<malwaredb_api::Report> {
427 self.client
428 .get(format!(
429 "{}{}/{hash}",
430 self.url,
431 malwaredb_api::SAMPLE_REPORT
432 ))
433 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
434 .send()?
435 .json::<malwaredb_api::Report>()
436 .context("failed to receive or decode sample report, or invalid API key")
437 }
438
439 pub fn similar(&self, contents: &[u8]) -> Result<malwaredb_api::SimilarSamplesResponse> {
447 let mut hashes = vec![];
448 let ssdeep_hash = FuzzyHash::new(contents);
449
450 let build_hasher = Murmur3HashState::default();
451 let lzjd_str =
452 LZDict::from_bytes_stream(contents.iter().copied(), &build_hasher).to_string();
453 hashes.push((malwaredb_api::SimilarityHashType::LZJD, lzjd_str));
454 hashes.push((
455 malwaredb_api::SimilarityHashType::SSDeep,
456 ssdeep_hash.to_string(),
457 ));
458
459 let mut builder = TlshBuilder::new(
460 tlsh_fixed::BucketKind::Bucket256,
461 tlsh_fixed::ChecksumKind::ThreeByte,
462 tlsh_fixed::Version::Version4,
463 );
464
465 builder.update(contents);
466 if let Ok(hasher) = builder.build() {
467 hashes.push((malwaredb_api::SimilarityHashType::TLSH, hasher.hash()));
468 }
469
470 if let Ok(exe) = EXE::from(contents) {
471 if let Some(imports) = exe.imports {
472 hashes.push((
473 malwaredb_api::SimilarityHashType::ImportHash,
474 hex::encode(imports.hash()),
475 ));
476 hashes.push((
477 malwaredb_api::SimilarityHashType::FuzzyImportHash,
478 imports.fuzzy_hash(),
479 ));
480 }
481 }
482
483 let request = malwaredb_api::SimilarSamplesRequest { hashes };
484
485 self.client
486 .post(format!("{}{}", self.url, malwaredb_api::SIMILAR_SAMPLES))
487 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
488 .json(&request)
489 .send()?
490 .json::<malwaredb_api::SimilarSamplesResponse>()
491 .context("failed to receive or decode similarity response, or invalid API key")
492 }
493}
494
495impl Debug for MdbClient {
496 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
497 use crate::MDB_VERSION;
498
499 writeln!(f, "MDB Client v{MDB_VERSION}: {}", self.url)
500 }
501}