1#![doc = include_str!("../README.md")]
4#![cfg_attr(docsrs, feature(doc_cfg))]
5#![deny(missing_docs)]
6#![deny(clippy::all)]
7#![deny(clippy::pedantic)]
8#![forbid(unsafe_code)]
9
10#[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
12#[cfg(feature = "blocking")]
13pub mod blocking;
14
15pub use malwaredb_api;
16use malwaredb_api::{
17 GetAPIKeyResponse, GetUserInfoResponse, Labels, PartialHashSearchType, Report, SearchRequest,
18 SearchRequestParameters, SearchResponse, SearchType, ServerInfo, ServerResponse,
19 SimilarSamplesResponse, Sources, SupportedFileTypes, YaraSearchRequest,
20 YaraSearchRequestResponse, YaraSearchResponse, digest::HashType,
21};
22use malwaredb_types::exec::pe32::EXE;
23use malwaredb_types::utils::entropy_calc;
24
25use std::collections::HashSet;
26use std::fmt::{Debug, Display, Formatter};
27use std::fs::OpenOptions;
28use std::io::{Cursor, Write};
29use std::path::{Path, PathBuf};
30use std::sync::LazyLock;
31
32use anyhow::{Context, Result, bail, ensure};
33use base64::Engine;
34use base64::engine::general_purpose;
35use cart_container::JsonMap;
36use fuzzyhash::FuzzyHash;
37use home::home_dir;
38use malwaredb_lzjd2::lzjd::LzDigest;
39use mdns_sd::{ServiceDaemon, ServiceEvent};
40use reqwest::Certificate;
41use serde::{Deserialize, Serialize};
42use sha2::{Digest, Sha256, Sha384, Sha512};
43use tlsh_fixed::TlshBuilder;
44use tracing::{debug, error, info, trace, warn};
45use uuid::Uuid;
46use zeroize::{Zeroize, ZeroizeOnDrop};
47
48const MDB_CLIENT_DIR: &str = "malwaredb_client";
50
51pub(crate) const MDB_CLIENT_ERROR_CONTEXT: &str =
53 "Network error connecting to MalwareDB, or failure to decode server response.";
54
55const MDB_CLIENT_CONFIG_TOML: &str = "mdb_client.toml";
57
58pub const MDB_VERSION: &str = env!("CARGO_PKG_VERSION");
60
61pub static MDB_VERSION_SEMVER: LazyLock<semver::Version> =
63 LazyLock::new(|| semver::Version::parse(MDB_VERSION).unwrap());
64
65#[cfg(target_os = "macos")]
67pub(crate) mod macos {
68 use crate::CertificateType;
69
70 use anyhow::Result;
71 use reqwest::Certificate;
72 use security_framework::os::macos::keychain::SecKeychain;
73 use tracing::error;
74
75 const KEYCHAIN_ID: &str = "malwaredb-client";
77
78 const KEYCHAIN_URL: &str = "URL";
80
81 const KEYCHAIN_API_KEY: &str = "API_KEY";
83
84 const KEYCHAIN_CERTIFICATE_PEM: &str = "CERT_PEM";
86
87 const KEYCHAIN_CERTIFICATE_DER: &str = "CERT_DER";
89
90 #[derive(Clone)]
91 pub(crate) struct CertificateData {
92 pub cert_type: CertificateType,
93 pub cert_bytes: Vec<u8>,
94 }
95
96 impl CertificateData {
97 pub(crate) fn as_cert(&self) -> Result<Certificate> {
98 Ok(match self.cert_type {
99 CertificateType::PEM => Certificate::from_pem(&self.cert_bytes)?,
100 CertificateType::DER => Certificate::from_der(&self.cert_bytes)?,
101 })
102 }
103 }
104
105 pub fn save_credentials(url: &str, key: &str, cert: Option<CertificateData>) -> Result<()> {
107 let keychain = SecKeychain::default()?;
108
109 keychain.add_generic_password(KEYCHAIN_ID, KEYCHAIN_URL, url.as_bytes())?;
110 keychain.add_generic_password(KEYCHAIN_ID, KEYCHAIN_API_KEY, key.as_bytes())?;
111
112 if let Some(cert) = cert {
113 match cert.cert_type {
114 CertificateType::PEM => keychain.add_generic_password(
115 KEYCHAIN_ID,
116 KEYCHAIN_CERTIFICATE_PEM,
117 &cert.cert_bytes,
118 )?,
119 CertificateType::DER => keychain.add_generic_password(
120 KEYCHAIN_ID,
121 KEYCHAIN_CERTIFICATE_DER,
122 &cert.cert_bytes,
123 )?,
124 }
125 }
126
127 Ok(())
128 }
129
130 pub fn retrieve_credentials() -> Result<(String, String, Option<CertificateData>)> {
132 let keychain = SecKeychain::default()?;
133 let (api_key, _item) = keychain.find_generic_password(KEYCHAIN_ID, KEYCHAIN_API_KEY)?;
134 let api_key = String::from_utf8(api_key.as_ref().to_vec())?;
135 let (url, _item) = keychain.find_generic_password(KEYCHAIN_ID, KEYCHAIN_URL)?;
136 let url = String::from_utf8(url.as_ref().to_vec())?;
137
138 if let Ok((cert, _item)) =
139 keychain.find_generic_password(KEYCHAIN_ID, KEYCHAIN_CERTIFICATE_PEM)
140 {
141 let cert = CertificateData {
142 cert_type: CertificateType::PEM,
143 cert_bytes: cert.to_vec(),
144 };
145 return Ok((api_key, url, Some(cert)));
146 }
147
148 if let Ok((cert, _item)) =
149 keychain.find_generic_password(KEYCHAIN_ID, KEYCHAIN_CERTIFICATE_DER)
150 {
151 let cert = CertificateData {
152 cert_type: CertificateType::DER,
153 cert_bytes: cert.to_vec(),
154 };
155 return Ok((api_key, url, Some(cert)));
156 }
157
158 Ok((api_key, url, None))
159 }
160
161 pub fn clear_credentials() {
163 if let Ok(keychain) = SecKeychain::default() {
164 for element in [
165 KEYCHAIN_API_KEY,
166 KEYCHAIN_URL,
167 KEYCHAIN_CERTIFICATE_PEM,
168 KEYCHAIN_CERTIFICATE_DER,
169 ] {
170 if let Ok((_, item)) = keychain.find_generic_password(KEYCHAIN_ID, element) {
171 item.delete();
172 }
173 }
174 } else {
175 error!("Failed to get access to the Keychain to clear credentials");
176 }
177 }
178}
179
180#[allow(clippy::upper_case_acronyms)]
181#[derive(Copy, Clone, PartialEq, Eq)]
182enum CertificateType {
183 DER,
184 PEM,
185}
186
187#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
189pub struct MdbClient {
190 pub url: String,
192
193 api_key: String,
195
196 #[zeroize(skip)]
198 #[serde(skip)]
199 client: reqwest::Client,
200
201 #[cfg(target_os = "macos")]
203 #[zeroize(skip)]
204 #[serde(skip)]
205 cert: Option<macos::CertificateData>,
206}
207
208impl MdbClient {
209 pub fn new(url: String, api_key: String, cert_path: Option<PathBuf>) -> Result<Self> {
216 let mut url = url;
217 let url = if url.ends_with('/') {
218 url.pop();
219 url
220 } else {
221 url
222 };
223
224 let cert = if let Some(path) = cert_path {
225 Some((path_load_cert(&path)?, path))
226 } else {
227 None
228 };
229
230 let builder = reqwest::ClientBuilder::new()
231 .gzip(true)
232 .zstd(true)
233 .use_rustls_tls()
234 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")));
235
236 let client = if let Some(((_cert_type, cert), _cert_path)) = &cert {
237 builder.add_root_certificate(cert.clone()).build()
238 } else {
239 builder.build()
240 }?;
241
242 #[cfg(target_os = "macos")]
243 let cert = if let Some(((cert_type, _cert), cert_path)) = &cert {
244 Some(macos::CertificateData {
245 cert_type: *cert_type,
246 cert_bytes: std::fs::read(cert_path)?,
247 })
248 } else {
249 None
250 };
251
252 Ok(Self {
253 url,
254 api_key,
255 client,
256
257 #[cfg(target_os = "macos")]
258 cert,
259 })
260 }
261
262 pub async fn login(
269 url: String,
270 username: String,
271 password: String,
272 save: bool,
273 cert_path: Option<PathBuf>,
274 ) -> Result<Self> {
275 let mut url = url;
276 let url = if url.ends_with('/') {
277 url.pop();
278 url
279 } else {
280 url
281 };
282
283 let api_request = malwaredb_api::GetAPIKeyRequest {
284 user: username,
285 password,
286 };
287
288 let builder = reqwest::ClientBuilder::new()
289 .gzip(true)
290 .zstd(true)
291 .use_rustls_tls()
292 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")));
293
294 let cert = if let Some(path) = cert_path {
295 Some((path_load_cert(&path)?, path))
296 } else {
297 None
298 };
299
300 let client = if let Some(((_cert_type, cert), _cert_path)) = &cert {
301 builder.add_root_certificate(cert.clone()).build()
302 } else {
303 builder.build()
304 }?;
305
306 let res = client
307 .post(format!("{url}{}", malwaredb_api::USER_LOGIN_URL))
308 .json(&api_request)
309 .send()
310 .await?
311 .json::<ServerResponse<GetAPIKeyResponse>>()
312 .await
313 .context(MDB_CLIENT_ERROR_CONTEXT)?;
314
315 let res = match res {
316 ServerResponse::Success(res) => res,
317 ServerResponse::Error(err) => return Err(err.into()),
318 };
319
320 #[cfg(target_os = "macos")]
321 let cert = if let Some(((cert_type, _cert), cert_path)) = &cert {
322 Some(macos::CertificateData {
323 cert_type: *cert_type,
324 cert_bytes: std::fs::read(cert_path)?,
325 })
326 } else {
327 None
328 };
329
330 let client = MdbClient {
331 url,
332 api_key: res.key.clone(),
333 client,
334
335 #[cfg(target_os = "macos")]
336 cert,
337 };
338
339 let server_info = client.server_info().await?;
340 if server_info.mdb_version > *MDB_VERSION_SEMVER {
341 warn!(
342 "Server version {:?} is newer than client {:?}, consider updating.",
343 server_info.mdb_version, MDB_VERSION_SEMVER
344 );
345 }
346
347 if save && let Err(e) = client.save() {
348 error!("Login successful but failed to save config: {e}");
349 bail!("Login successful but failed to save config: {e}");
350 }
351 Ok(client)
352 }
353
354 pub async fn reset_key(&self) -> Result<()> {
360 let response = self
361 .client
362 .get(format!("{}{}", self.url, malwaredb_api::USER_LOGOUT_URL))
363 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
364 .send()
365 .await
366 .context(MDB_CLIENT_ERROR_CONTEXT)?;
367 if !response.status().is_success() {
368 bail!("failed to reset API key, was it correct?");
369 }
370 Ok(())
371 }
372
373 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
380 let name = path.as_ref().display();
381 let config =
382 std::fs::read_to_string(&path).context(format!("failed to read config file {name}"))?;
383 let cfg: MdbClient =
384 toml::from_str(&config).context(format!("failed to parse config file {name}"))?;
385 Ok(cfg)
386 }
387
388 pub fn load() -> Result<Self> {
397 #[cfg(target_os = "macos")]
398 {
399 if let Ok((api_key, url, cert)) = macos::retrieve_credentials() {
400 let builder = reqwest::ClientBuilder::new()
401 .gzip(true)
402 .zstd(true)
403 .use_rustls_tls()
404 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")));
405
406 let client = if let Some(cert) = &cert {
407 builder.add_root_certificate(cert.as_cert()?).build()
408 } else {
409 builder.build()
410 }?;
411
412 return Ok(Self {
413 url,
414 api_key,
415 client,
416 cert,
417 });
418 }
419 }
420
421 let path = get_config_path(false)?;
422 if path.exists() {
423 return Self::from_file(path);
424 }
425 bail!("config file not found")
426 }
427
428 pub fn save(&self) -> Result<()> {
436 #[cfg(target_os = "macos")]
437 {
438 if macos::save_credentials(&self.url, &self.api_key, self.cert.clone()).is_ok() {
439 return Ok(());
440 }
441 }
442
443 let toml = toml::to_string(self)?;
444 let path = get_config_path(true)?;
445
446 let mut options = OpenOptions::new();
447 options
448 .write(true)
449 .create(true)
450 .append(false)
451 .truncate(false);
452
453 #[cfg(target_family = "unix")]
454 {
455 use std::os::unix::fs::OpenOptionsExt;
456
457 options.mode(0o600);
458 }
459
460 let mut file = options.open(&path)?;
461 write!(file, "{toml}").context(format!("failed to write mdb config to {}", path.display()))
462 }
463
464 pub fn delete(&self) -> Result<()> {
471 #[cfg(target_os = "macos")]
472 macos::clear_credentials();
473
474 let path = get_config_path(false)?;
475 if path.exists() {
476 std::fs::remove_file(&path)
477 .context(format!("failed to delete client config file {}", path.display()))?;
478 }
479 Ok(())
480 }
481
482 pub async fn server_info(&self) -> Result<ServerInfo> {
490 let response = self
491 .client
492 .get(format!("{}{}", self.url, malwaredb_api::SERVER_INFO_URL))
493 .send()
494 .await?
495 .json::<ServerResponse<ServerInfo>>()
496 .await
497 .context(MDB_CLIENT_ERROR_CONTEXT)?;
498
499 match response {
500 ServerResponse::Success(info) => Ok(info),
501 ServerResponse::Error(e) => Err(e.into()),
502 }
503 }
504
505 pub async fn supported_types(&self) -> Result<SupportedFileTypes> {
511 let response = self
512 .client
513 .get(format!("{}{}", self.url, malwaredb_api::SUPPORTED_FILE_TYPES_URL))
514 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
515 .send()
516 .await?
517 .json::<ServerResponse<SupportedFileTypes>>()
518 .await
519 .context(MDB_CLIENT_ERROR_CONTEXT)?;
520
521 match response {
522 ServerResponse::Success(types) => Ok(types),
523 ServerResponse::Error(e) => Err(e.into()),
524 }
525 }
526
527 pub async fn whoami(&self) -> Result<GetUserInfoResponse> {
534 let response = self
535 .client
536 .get(format!("{}{}", self.url, malwaredb_api::USER_INFO_URL))
537 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
538 .send()
539 .await?
540 .json::<ServerResponse<GetUserInfoResponse>>()
541 .await
542 .context(MDB_CLIENT_ERROR_CONTEXT)?;
543
544 match response {
545 ServerResponse::Success(info) => Ok(info),
546 ServerResponse::Error(e) => Err(e.into()),
547 }
548 }
549
550 pub async fn labels(&self) -> Result<Labels> {
557 let response = self
558 .client
559 .get(format!("{}{}", self.url, malwaredb_api::LIST_LABELS_URL))
560 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
561 .send()
562 .await?
563 .json::<ServerResponse<Labels>>()
564 .await
565 .context(MDB_CLIENT_ERROR_CONTEXT)?;
566
567 match response {
568 ServerResponse::Success(labels) => Ok(labels),
569 ServerResponse::Error(e) => Err(e.into()),
570 }
571 }
572
573 pub async fn sources(&self) -> Result<Sources> {
580 let response = self
581 .client
582 .get(format!("{}{}", self.url, malwaredb_api::LIST_SOURCES_URL))
583 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
584 .send()
585 .await?
586 .json::<ServerResponse<Sources>>()
587 .await
588 .context(MDB_CLIENT_ERROR_CONTEXT)?;
589
590 match response {
591 ServerResponse::Success(sources) => Ok(sources),
592 ServerResponse::Error(e) => Err(e.into()),
593 }
594 }
595
596 pub async fn submit(
603 &self,
604 contents: impl AsRef<[u8]>,
605 file_name: impl AsRef<str>,
606 source_id: u32,
607 ) -> Result<bool> {
608 let mut hasher = Sha256::new();
609 hasher.update(&contents);
610 let result = hasher.finalize();
611
612 let encoded = general_purpose::STANDARD.encode(contents);
613
614 let payload = malwaredb_api::NewSampleB64 {
615 file_name: file_name.as_ref().to_string(),
616 source_id,
617 file_contents_b64: encoded,
618 sha256: hex::encode(result),
619 };
620
621 match self
622 .client
623 .post(format!("{}{}", self.url, malwaredb_api::UPLOAD_SAMPLE_JSON_URL))
624 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
625 .json(&payload)
626 .send()
627 .await
628 {
629 Ok(res) => {
630 if !res.status().is_success() {
631 info!("Code {} sending {}", res.status(), payload.file_name);
632 }
633 Ok(res.status().is_success())
634 }
635 Err(e) => {
636 let status: String = e
637 .status()
638 .map(|s| s.as_str().to_string())
639 .unwrap_or_default();
640 error!("Error{status} sending {}: {e}", payload.file_name);
641 bail!(e.to_string())
642 }
643 }
644 }
645
646 pub async fn submit_as_cbor(
654 &self,
655 contents: impl AsRef<[u8]>,
656 file_name: impl AsRef<str>,
657 source_id: u32,
658 ) -> Result<bool> {
659 let mut hasher = Sha256::new();
660 hasher.update(&contents);
661 let result = hasher.finalize();
662
663 let payload = malwaredb_api::NewSampleBytes {
664 file_name: file_name.as_ref().to_string(),
665 source_id,
666 file_contents: contents.as_ref().to_vec(),
667 sha256: hex::encode(result),
668 };
669
670 let mut bytes = Vec::with_capacity(payload.file_contents.len());
671 ciborium::ser::into_writer(&payload, &mut bytes)?;
672
673 match self
674 .client
675 .post(format!("{}{}", self.url, malwaredb_api::UPLOAD_SAMPLE_CBOR_URL))
676 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
677 .header("content-type", "application/cbor")
678 .body(bytes)
679 .send()
680 .await
681 {
682 Ok(res) => {
683 if !res.status().is_success() {
684 info!("Code {} sending {}", res.status(), payload.file_name);
685 }
686 Ok(res.status().is_success())
687 }
688 Err(e) => {
689 let status: String = e
690 .status()
691 .map(|s| s.as_str().to_string())
692 .unwrap_or_default();
693 error!("Error{status} sending {}: {e}", payload.file_name);
694 bail!(e.to_string())
695 }
696 }
697 }
698
699 pub async fn partial_search(
705 &self,
706 partial_hash: Option<(PartialHashSearchType, String)>,
707 name: Option<String>,
708 response: PartialHashSearchType,
709 limit: u32,
710 ) -> Result<SearchResponse> {
711 let query = SearchRequest {
712 search: SearchType::Search(SearchRequestParameters {
713 partial_hash,
714 file_name: name,
715 response,
716 limit,
717 labels: None,
718 file_type: None,
719 magic: None,
720 }),
721 };
722
723 self.do_search_request(&query).await
724 }
725
726 #[allow(clippy::too_many_arguments)]
732 pub async fn partial_search_labels_type(
733 &self,
734 partial_hash: Option<(PartialHashSearchType, String)>,
735 name: Option<String>,
736 response: PartialHashSearchType,
737 labels: Option<Vec<String>>,
738 file_type: Option<String>,
739 magic: Option<String>,
740 limit: u32,
741 ) -> Result<SearchResponse> {
742 let query = SearchRequest {
743 search: SearchType::Search(SearchRequestParameters {
744 partial_hash,
745 file_name: name,
746 response,
747 limit,
748 file_type,
749 magic,
750 labels,
751 }),
752 };
753
754 self.do_search_request(&query).await
755 }
756
757 pub async fn next_page_search(&self, response: &SearchResponse) -> Result<SearchResponse> {
763 if let Some(uuid) = response.pagination {
764 let request = SearchRequest {
765 search: SearchType::Continuation(uuid),
766 };
767 return self.do_search_request(&request).await;
768 }
769
770 bail!("Pagination not available")
771 }
772
773 async fn do_search_request(&self, query: &SearchRequest) -> Result<SearchResponse> {
774 ensure!(
775 query.is_valid(),
776 "Query isn't valid: hash isn't hexidecimal or both the hashes and file name are empty"
777 );
778
779 let response = self
780 .client
781 .post(format!("{}{}", self.url, malwaredb_api::SEARCH_URL))
782 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
783 .json(query)
784 .send()
785 .await?
786 .json::<ServerResponse<SearchResponse>>()
787 .await
788 .context(MDB_CLIENT_ERROR_CONTEXT)?;
789
790 match response {
791 ServerResponse::Success(search) => Ok(search),
792 ServerResponse::Error(e) => Err(e.into()),
793 }
794 }
795
796 pub async fn retrieve(&self, hash: &str, cart: bool) -> Result<Vec<u8>> {
803 let api_endpoint = if cart {
804 format!("{}{hash}", malwaredb_api::DOWNLOAD_SAMPLE_CART_URL)
805 } else {
806 format!("{}{hash}", malwaredb_api::DOWNLOAD_SAMPLE_URL)
807 };
808
809 let res = self
810 .client
811 .get(format!("{}{api_endpoint}", self.url))
812 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
813 .send()
814 .await?;
815
816 if !res.status().is_success() {
817 bail!("Received code {}", res.status());
818 }
819
820 let content_digest = res.headers().get("content-digest").map(ToOwned::to_owned);
821 let body = res.bytes().await?;
822 let bytes = body.to_vec();
823
824 if let Some(digest) = content_digest {
826 let hash = HashType::from_content_digest_header(digest.to_str()?)?;
827 if hash.verify(&bytes) {
828 trace!("Hash verified for sample {hash}");
829 } else {
830 error!("Hash mismatch for sample {hash}");
831 }
832 } else {
833 warn!("No content digest header received for sample {hash}");
834 }
835
836 Ok(bytes)
837 }
838
839 pub async fn report(&self, hash: &str) -> Result<Report> {
846 let response = self
847 .client
848 .get(format!("{}{}/{hash}", self.url, malwaredb_api::SAMPLE_REPORT_URL))
849 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
850 .send()
851 .await?
852 .json::<ServerResponse<Report>>()
853 .await
854 .context(MDB_CLIENT_ERROR_CONTEXT)?;
855
856 match response {
857 ServerResponse::Success(report) => Ok(report),
858 ServerResponse::Error(e) => Err(e.into()),
859 }
860 }
861
862 pub async fn similar(&self, contents: &[u8]) -> Result<SimilarSamplesResponse> {
870 let mut hashes = vec![];
871 let ssdeep_hash = FuzzyHash::new(contents);
872
873 let lzjd_str = LzDigest::from(contents).to_string();
874 hashes.push((malwaredb_api::SimilarityHashType::LZJD, lzjd_str));
875 hashes.push((malwaredb_api::SimilarityHashType::SSDeep, ssdeep_hash.to_string()));
876
877 let mut builder = TlshBuilder::new(
878 tlsh_fixed::BucketKind::Bucket256,
879 tlsh_fixed::ChecksumKind::ThreeByte,
880 tlsh_fixed::Version::Version4,
881 );
882
883 builder.update(contents);
884 if let Ok(hasher) = builder.build() {
885 hashes.push((malwaredb_api::SimilarityHashType::TLSH, hasher.hash()));
886 }
887
888 if let Ok(exe) = EXE::from(contents)
889 && let Some(imports) = exe.imports
890 {
891 hashes
892 .push((malwaredb_api::SimilarityHashType::ImportHash, hex::encode(imports.hash())));
893 hashes.push((malwaredb_api::SimilarityHashType::FuzzyImportHash, imports.fuzzy_hash()));
894 }
895
896 let request = malwaredb_api::SimilarSamplesRequest { hashes };
897
898 let response = self
899 .client
900 .post(format!("{}{}", self.url, malwaredb_api::SIMILAR_SAMPLES_URL))
901 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
902 .json(&request)
903 .send()
904 .await?
905 .json::<ServerResponse<SimilarSamplesResponse>>()
906 .await
907 .context(MDB_CLIENT_ERROR_CONTEXT)?;
908
909 match response {
910 ServerResponse::Success(similar) => Ok(similar),
911 ServerResponse::Error(e) => Err(e.into()),
912 }
913 }
914
915 pub async fn yara_search(&self, yara: &str) -> Result<YaraSearchRequestResponse> {
921 let yara = YaraSearchRequest {
922 rules: vec![yara.to_string()],
923 response: PartialHashSearchType::SHA256,
924 };
925
926 let response = self
927 .client
928 .post(format!("{}{}", self.url, malwaredb_api::YARA_SEARCH_URL))
929 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
930 .json(&yara)
931 .send()
932 .await?
933 .json::<ServerResponse<YaraSearchRequestResponse>>()
934 .await?;
935
936 match response {
937 ServerResponse::Success(sources) => Ok(sources),
938 ServerResponse::Error(e) => Err(e.into()),
939 }
940 }
941
942 pub async fn yara_result(&self, uuid: Uuid) -> Result<YaraSearchResponse> {
948 let response = self
949 .client
950 .get(format!("{}{}/{uuid}", self.url, malwaredb_api::YARA_SEARCH_URL))
951 .header(malwaredb_api::MDB_API_HEADER, &self.api_key)
952 .send()
953 .await?
954 .json::<ServerResponse<YaraSearchResponse>>()
955 .await?;
956
957 match response {
958 ServerResponse::Success(sources) => Ok(sources),
959 ServerResponse::Error(e) => Err(e.into()),
960 }
961 }
962}
963
964impl Debug for MdbClient {
965 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
966 writeln!(f, "MDB Client v{MDB_VERSION}: {}", self.url)
967 }
968}
969
970pub fn encode_to_cart(data: &[u8]) -> Result<Vec<u8>> {
979 let mut input_buffer = Cursor::new(data);
980 let mut output_buffer = Cursor::new(vec![]);
981 let mut output_metadata = JsonMap::new();
982
983 let mut sha384 = Sha384::new();
984 sha384.update(data);
985 let sha384 = hex::encode(sha384.finalize());
986
987 let mut sha512 = Sha512::new();
988 sha512.update(data);
989 let sha512 = hex::encode(sha512.finalize());
990
991 output_metadata.insert("sha384".into(), sha384.into());
992 output_metadata.insert("sha512".into(), sha512.into());
993 output_metadata.insert("entropy".into(), entropy_calc(data).into());
994 cart_container::pack_stream(
995 &mut input_buffer,
996 &mut output_buffer,
997 Some(output_metadata),
998 None,
999 cart_container::digesters::default_digesters(),
1000 None,
1001 )?;
1002
1003 Ok(output_buffer.into_inner())
1004}
1005
1006pub fn decode_from_cart(data: &[u8]) -> Result<(Vec<u8>, Option<JsonMap>, Option<JsonMap>)> {
1015 let mut input_buffer = Cursor::new(data);
1016 let mut output_buffer = Cursor::new(vec![]);
1017 let (header, footer) =
1018 cart_container::unpack_stream(&mut input_buffer, &mut output_buffer, None)?;
1019 Ok((output_buffer.into_inner(), header, footer))
1020}
1021
1022fn path_load_cert(path: &Path) -> Result<(CertificateType, Certificate)> {
1029 if !path.exists() {
1030 bail!("Certificate {} does not exist.", path.display());
1031 }
1032 let cert = match path
1033 .extension()
1034 .context("can't determine file extension")?
1035 .to_str()
1036 .context("unable to parse file extension")?
1037 {
1038 "pem" => {
1039 let contents = std::fs::read(path)?;
1040 (CertificateType::PEM, Certificate::from_pem(&contents)?)
1041 }
1042 "der" => {
1043 let contents = std::fs::read(path)?;
1044 (CertificateType::DER, Certificate::from_der(&contents)?)
1045 }
1046 ext => {
1047 bail!("Unknown extension {ext:?}")
1048 }
1049 };
1050 Ok(cert)
1051}
1052
1053#[inline]
1061pub(crate) fn get_config_path(create: bool) -> Result<PathBuf> {
1062 let config = PathBuf::from(MDB_CLIENT_CONFIG_TOML);
1064 if config.exists() {
1065 return Ok(config);
1066 }
1067
1068 #[cfg(target_os = "haiku")]
1069 {
1070 let mut settings = PathBuf::from("/boot/home/config/settings/malwaredb");
1071 if create && !settings.exists() {
1072 std::fs::create_dir_all(&settings)?;
1073 }
1074 settings.push(MDB_CLIENT_CONFIG_TOML);
1075 return Ok(settings);
1076 }
1077
1078 #[cfg(unix)]
1079 {
1080 if let Some(xdg_home) = std::env::var_os("XDG_CONFIG_HOME") {
1082 let mut xdg_config_home = PathBuf::from(xdg_home);
1083 xdg_config_home.push(MDB_CLIENT_DIR);
1084 if create && !xdg_config_home.exists() {
1085 std::fs::create_dir_all(&xdg_config_home)?;
1086 }
1087 xdg_config_home.push(MDB_CLIENT_CONFIG_TOML);
1088 return Ok(xdg_config_home);
1089 }
1090 }
1091
1092 if let Some(mut home_config) = home_dir() {
1093 home_config.push(".config");
1094 home_config.push(MDB_CLIENT_DIR);
1095 if create && !home_config.exists() {
1096 std::fs::create_dir_all(&home_config)?;
1097 }
1098 home_config.push(MDB_CLIENT_CONFIG_TOML);
1099 return Ok(home_config);
1100 }
1101
1102 Ok(PathBuf::from(MDB_CLIENT_CONFIG_TOML))
1103}
1104
1105#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1107pub struct MalwareDBServer {
1108 pub host: String,
1110
1111 pub port: u16,
1113
1114 pub ssl: bool,
1116
1117 pub name: String,
1119}
1120
1121impl Display for MalwareDBServer {
1122 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1123 if self.ssl {
1124 write!(f, "https://{}:{}", self.host, self.port)
1125 } else {
1126 write!(f, "http://{}:{}", self.host, self.port)
1127 }
1128 }
1129}
1130
1131impl MalwareDBServer {
1132 pub async fn server_info(&self) -> Result<ServerInfo> {
1138 let client = reqwest::ClientBuilder::new()
1139 .gzip(true)
1140 .zstd(true)
1141 .use_rustls_tls()
1142 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")))
1143 .build()?;
1144
1145 let response = client
1146 .get(format!("{self}{}", malwaredb_api::SERVER_INFO_URL))
1147 .send()
1148 .await?
1149 .json::<ServerResponse<ServerInfo>>()
1150 .await
1151 .context(MDB_CLIENT_ERROR_CONTEXT)?;
1152
1153 match response {
1154 ServerResponse::Success(info) => Ok(info),
1155 ServerResponse::Error(e) => Err(e.into()),
1156 }
1157 }
1158
1159 #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
1169 #[cfg(feature = "blocking")]
1170 pub fn server_info_blocking(&self) -> Result<ServerInfo> {
1171 let client = reqwest::blocking::ClientBuilder::new()
1172 .gzip(true)
1173 .zstd(true)
1174 .use_rustls_tls()
1175 .user_agent(concat!("mdb_client/", env!("CARGO_PKG_VERSION")))
1176 .build()?;
1177
1178 let response = client
1179 .get(format!("{self}{}", malwaredb_api::SERVER_INFO_URL))
1180 .send()?
1181 .json::<ServerResponse<ServerInfo>>()
1182 .context(MDB_CLIENT_ERROR_CONTEXT)?;
1183
1184 match response {
1185 ServerResponse::Success(similar) => Ok(similar),
1186 ServerResponse::Error(e) => Err(e.into()),
1187 }
1188 }
1189}
1190
1191pub fn discover_servers() -> Result<Vec<MalwareDBServer>> {
1197 const MAX_ITERS: usize = 5;
1198 let mdns = ServiceDaemon::new()?;
1199 let mut servers = HashSet::new();
1200 let receiver = mdns.browse(malwaredb_api::MDNS_NAME)?;
1201
1202 let mut counter = 0;
1203 while let Ok(event) = receiver.recv() {
1204 if let ServiceEvent::ServiceResolved(resolved) = event {
1205 let host = resolved.host.replace(".local.", "");
1206 let ssl = if let Some(ssl) = resolved.txt_properties.get("ssl") {
1207 ssl.val_str() == "true"
1208 } else {
1209 debug!(
1210 "MalwareDB entry for {host}:{} doesn't specify ssl, assuming not",
1211 resolved.port
1212 );
1213 false
1214 };
1215
1216 let server = MalwareDBServer {
1217 host,
1218 port: resolved.port,
1219 ssl,
1220 name: resolved.fullname.replace(malwaredb_api::MDNS_NAME, ""),
1221 };
1222
1223 servers.insert(server);
1224 }
1225 counter += 1;
1226 if counter > MAX_ITERS {
1227 break;
1228 }
1229 }
1230
1231 if mdns.shutdown().is_err() {
1232 }
1234 Ok(servers.into_iter().collect())
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240
1241 #[test]
1242 fn cart() {
1243 const BYTES: &[u8] = include_bytes!("../../crates/types/testdata/elf/elf_haiku_x86.cart");
1244 const ORIGINAL_SHA256: &str =
1245 "de10ba5e5402b46ea975b5cb8a45eb7df9e81dc81012fd4efd145ed2dce3a740";
1246
1247 let (decoded, header, footer) = decode_from_cart(BYTES).unwrap();
1248
1249 let mut sha256 = Sha256::new();
1250 sha256.update(&decoded);
1251 let sha256 = hex::encode(sha256.finalize());
1252 assert_eq!(sha256, ORIGINAL_SHA256);
1253
1254 let header = header.unwrap();
1255 let entropy = header.get("entropy").unwrap().as_f64().unwrap();
1256 assert!(entropy > 4.0 && entropy < 4.1);
1257
1258 let footer = footer.unwrap();
1259 assert_eq!(footer.get("length").unwrap(), "5093");
1260 assert_eq!(footer.get("sha256").unwrap(), ORIGINAL_SHA256);
1261 }
1262}