Skip to main content

max / makenotwork

9.5 KB · 275 lines History Blame Raw
1 //! Layer 9: MetaDefender Cloud hash reputation (second opinion).
2 //!
3 //! Hash-lookup only, no file uploaded. Free tier from OPSWAT: 4,000
4 //! reputation queries/day, 24 requests/min (per their docs). Commercial use
5 //! is licensed under the free tier, unlike VirusTotal's free tier which
6 //! forbids it.
7 //!
8 //! **Gating: second-opinion only.** This layer does NOT run on every upload.
9 //! The pipeline aggregator invokes it only when another layer flagged the
10 //! file as suspicious (YARA hit, ClamAV warning, MalwareBazaar match). On
11 //! a busy day we don't want to spend our quota on uncontroversial uploads.
12 //! See `ScanPipeline::scan` where the gating happens.
13 //!
14 //! Verdict semantics:
15 //! - `Pass`, MetaDefender returns clean across all engines, OR has no
16 //! record of the hash (a clean engine consensus + an absent hash both
17 //! weaken suspicion from the prior layer).
18 //! - `Fail`, at least one engine in MetaDefender's array flagged the hash.
19 //! The detail names the threat label and the engines that hit.
20 //! - `Skip`, no API key configured, OR not invoked (no prior suspicion).
21 //! - `Error`, network failure, rate-limit, auth failure. Fail-open by
22 //! policy; the dashboard surfaces health.
23
24 use std::time::Duration;
25
26 use crate::constants;
27
28 use super::{ErrorPolicy, LayerResult, LayerVerdict};
29
30 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen;
31
32 const API_URL_PREFIX: &str = "https://api.metadefender.com/v4/hash/";
33
34 /// Query MetaDefender Cloud for a hash's multi-engine verdict. Returns the
35 /// layer's `Skip` if no API key is configured (the gating decision lives in
36 /// the caller, we only short-circuit on missing config here).
37 pub async fn check_metadefender(sha256: &str, api_key: Option<&str>) -> LayerResult {
38 let Some(key) = api_key else {
39 return LayerResult {
40 layer: "metadefender",
41 verdict: LayerVerdict::Skip,
42 detail: Some("No MetaDefender API key configured".to_string()),
43 };
44 };
45
46 let timeout = Duration::from_secs(constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS);
47
48 match tokio::time::timeout(timeout, query_hash(sha256, key)).await {
49 Ok(Ok(result)) => result,
50 Ok(Err(e)) => LayerResult {
51 layer: "metadefender",
52 verdict: LayerVerdict::Error,
53 detail: Some(format!("MetaDefender error: {e}")),
54 },
55 Err(_) => LayerResult {
56 layer: "metadefender",
57 verdict: LayerVerdict::Error,
58 detail: Some("MetaDefender lookup timed out".to_string()),
59 },
60 }
61 }
62
63 async fn query_hash(sha256: &str, api_key: &str) -> Result<LayerResult, String> {
64 // Explicit request + connect timeouts (Perf-S2); a default client has neither.
65 static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
66 crate::crypto::install_default_crypto_provider();
67 reqwest::Client::builder()
68 .timeout(std::time::Duration::from_secs(
69 crate::constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS,
70 ))
71 .connect_timeout(std::time::Duration::from_secs(
72 crate::constants::SCAN_HTTP_CONNECT_TIMEOUT_SECS,
73 ))
74 .build()
75 .unwrap_or_default()
76 });
77 let client = &*CLIENT;
78
79 let url = format!("{API_URL_PREFIX}{sha256}");
80 let response = client
81 .get(&url)
82 .header("apikey", api_key)
83 .send()
84 .await
85 .map_err(|e| format!("HTTP request failed: {e}"))?;
86
87 let status = response.status();
88 // 404 means "no record of this hash", that's a Pass for our purposes
89 // (absent + the prior layer's suspicion doesn't escalate).
90 if status.as_u16() == 404 {
91 return Ok(LayerResult {
92 layer: "metadefender",
93 verdict: LayerVerdict::Pass,
94 detail: Some("hash unknown to MetaDefender".to_string()),
95 });
96 }
97 if status.as_u16() == 401 || status.as_u16() == 403 {
98 return Ok(LayerResult {
99 layer: "metadefender",
100 verdict: LayerVerdict::Error,
101 detail: Some(format!("MetaDefender auth: HTTP {}", status.as_u16())),
102 });
103 }
104 if status.as_u16() == 429 {
105 return Ok(LayerResult {
106 layer: "metadefender",
107 verdict: LayerVerdict::Error,
108 detail: Some("MetaDefender rate-limited".to_string()),
109 });
110 }
111 if !status.is_success() {
112 return Err(format!("HTTP {} from MetaDefender", status.as_u16()));
113 }
114
115 let body: serde_json::Value = response
116 .json()
117 .await
118 .map_err(|e| format!("Failed to parse response: {e}"))?;
119
120 Ok(parse_metadefender_response(&body))
121 }
122
123 /// Parse MetaDefender's `/v4/hash/` response. Extracted for testability.
124 fn parse_metadefender_response(body: &serde_json::Value) -> LayerResult {
125 let scan_results = body.get("scan_results");
126 let Some(scan_results) = scan_results else {
127 return LayerResult {
128 layer: "metadefender",
129 verdict: LayerVerdict::Pass,
130 detail: Some("hash unknown to MetaDefender".to_string()),
131 };
132 };
133
134 let total_detected = scan_results
135 .get("total_detected_avs")
136 .and_then(serde_json::Value::as_i64)
137 .unwrap_or(0);
138 let total_avs = scan_results
139 .get("total_avs")
140 .and_then(serde_json::Value::as_i64)
141 .unwrap_or(0);
142
143 if total_detected == 0 {
144 return LayerResult {
145 layer: "metadefender",
146 verdict: LayerVerdict::Pass,
147 detail: Some(format!("clean ({total_detected}/{total_avs} engines)")),
148 };
149 }
150
151 // Identify which engines hit and what threat label they reported.
152 let mut threats: Vec<String> = Vec::new();
153 if let Some(details) = scan_results.get("scan_details").and_then(|d| d.as_object()) {
154 for (engine, info) in details {
155 let detected = info
156 .get("scan_result_i")
157 .and_then(serde_json::Value::as_i64)
158 .unwrap_or(0);
159 // 1 = infected, 2 = suspicious in MetaDefender's enum. 0 = clean.
160 if detected == 1 || detected == 2 {
161 let threat = info
162 .get("threat_found")
163 .and_then(|v| v.as_str())
164 .unwrap_or("malicious");
165 threats.push(format!("{engine}: {threat}"));
166 }
167 }
168 }
169 // Cap threat list length to keep the detail string sane.
170 threats.truncate(3);
171 let threats_str = if threats.is_empty() {
172 format!("{total_detected}/{total_avs} engines flagged")
173 } else {
174 format!(
175 "{}/{} engines flagged, {}",
176 total_detected,
177 total_avs,
178 threats.join(", ")
179 )
180 };
181 LayerResult {
182 layer: "metadefender",
183 verdict: LayerVerdict::Fail,
184 detail: Some(threats_str),
185 }
186 }
187
188 #[cfg(test)]
189 mod tests {
190 use super::*;
191 use serde_json::json;
192
193 #[tokio::test]
194 async fn no_api_key_returns_skip() {
195 let r = check_metadefender(&"0".repeat(64), None).await;
196 assert_eq!(r.verdict, LayerVerdict::Skip);
197 assert!(r.detail.unwrap().contains("No MetaDefender"));
198 }
199
200 #[test]
201 fn no_scan_results_field_passes_as_unknown() {
202 let body = json!({});
203 let r = parse_metadefender_response(&body);
204 assert_eq!(r.verdict, LayerVerdict::Pass);
205 assert!(r.detail.unwrap().contains("unknown"));
206 }
207
208 #[test]
209 fn zero_detections_passes_with_count() {
210 let body = json!({
211 "scan_results": {
212 "total_detected_avs": 0,
213 "total_avs": 30,
214 "scan_details": {}
215 }
216 });
217 let r = parse_metadefender_response(&body);
218 assert_eq!(r.verdict, LayerVerdict::Pass);
219 let d = r.detail.unwrap();
220 assert!(d.contains("0/30"));
221 }
222
223 #[test]
224 fn detected_yields_fail_with_engines() {
225 let body = json!({
226 "scan_results": {
227 "total_detected_avs": 2,
228 "total_avs": 30,
229 "scan_details": {
230 "AVG": {"scan_result_i": 1, "threat_found": "Trojan.Generic"},
231 "Bitdefender": {"scan_result_i": 1, "threat_found": "Trojan.Emotet"},
232 "Avast": {"scan_result_i": 0, "threat_found": ""},
233 }
234 }
235 });
236 let r = parse_metadefender_response(&body);
237 assert_eq!(r.verdict, LayerVerdict::Fail);
238 let d = r.detail.unwrap();
239 assert!(d.contains("2/30"));
240 // At least one of the two infected engines surfaced.
241 assert!(d.contains("Trojan.") || d.contains("AVG") || d.contains("Bitdefender"));
242 }
243
244 #[test]
245 fn suspicious_status_also_counts_as_fail() {
246 let body = json!({
247 "scan_results": {
248 "total_detected_avs": 1,
249 "total_avs": 30,
250 "scan_details": {
251 "EngineX": {"scan_result_i": 2, "threat_found": "Suspicious.Generic"},
252 }
253 }
254 });
255 let r = parse_metadefender_response(&body);
256 assert_eq!(r.verdict, LayerVerdict::Fail);
257 assert!(r.detail.unwrap().contains("Suspicious.Generic"));
258 }
259
260 #[test]
261 fn detected_without_threat_label_still_fails() {
262 let body = json!({
263 "scan_results": {
264 "total_detected_avs": 1,
265 "total_avs": 30,
266 "scan_details": {
267 "EngineX": {"scan_result_i": 1},
268 }
269 }
270 });
271 let r = parse_metadefender_response(&body);
272 assert_eq!(r.verdict, LayerVerdict::Fail);
273 }
274 }
275