Skip to main content

max / makenotwork

9.5 KB · 274 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 reqwest::Client::builder()
67 .timeout(std::time::Duration::from_secs(
68 crate::constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS,
69 ))
70 .connect_timeout(std::time::Duration::from_secs(
71 crate::constants::SCAN_HTTP_CONNECT_TIMEOUT_SECS,
72 ))
73 .build()
74 .unwrap_or_default()
75 });
76 let client = &*CLIENT;
77
78 let url = format!("{API_URL_PREFIX}{sha256}");
79 let response = client
80 .get(&url)
81 .header("apikey", api_key)
82 .send()
83 .await
84 .map_err(|e| format!("HTTP request failed: {e}"))?;
85
86 let status = response.status();
87 // 404 means "no record of this hash", that's a Pass for our purposes
88 // (absent + the prior layer's suspicion doesn't escalate).
89 if status.as_u16() == 404 {
90 return Ok(LayerResult {
91 layer: "metadefender",
92 verdict: LayerVerdict::Pass,
93 detail: Some("hash unknown to MetaDefender".to_string()),
94 });
95 }
96 if status.as_u16() == 401 || status.as_u16() == 403 {
97 return Ok(LayerResult {
98 layer: "metadefender",
99 verdict: LayerVerdict::Error,
100 detail: Some(format!("MetaDefender auth: HTTP {}", status.as_u16())),
101 });
102 }
103 if status.as_u16() == 429 {
104 return Ok(LayerResult {
105 layer: "metadefender",
106 verdict: LayerVerdict::Error,
107 detail: Some("MetaDefender rate-limited".to_string()),
108 });
109 }
110 if !status.is_success() {
111 return Err(format!("HTTP {} from MetaDefender", status.as_u16()));
112 }
113
114 let body: serde_json::Value = response
115 .json()
116 .await
117 .map_err(|e| format!("Failed to parse response: {e}"))?;
118
119 Ok(parse_metadefender_response(&body))
120 }
121
122 /// Parse MetaDefender's `/v4/hash/` response. Extracted for testability.
123 fn parse_metadefender_response(body: &serde_json::Value) -> LayerResult {
124 let scan_results = body.get("scan_results");
125 let Some(scan_results) = scan_results else {
126 return LayerResult {
127 layer: "metadefender",
128 verdict: LayerVerdict::Pass,
129 detail: Some("hash unknown to MetaDefender".to_string()),
130 };
131 };
132
133 let total_detected = scan_results
134 .get("total_detected_avs")
135 .and_then(serde_json::Value::as_i64)
136 .unwrap_or(0);
137 let total_avs = scan_results
138 .get("total_avs")
139 .and_then(serde_json::Value::as_i64)
140 .unwrap_or(0);
141
142 if total_detected == 0 {
143 return LayerResult {
144 layer: "metadefender",
145 verdict: LayerVerdict::Pass,
146 detail: Some(format!("clean ({total_detected}/{total_avs} engines)")),
147 };
148 }
149
150 // Identify which engines hit and what threat label they reported.
151 let mut threats: Vec<String> = Vec::new();
152 if let Some(details) = scan_results.get("scan_details").and_then(|d| d.as_object()) {
153 for (engine, info) in details {
154 let detected = info
155 .get("scan_result_i")
156 .and_then(serde_json::Value::as_i64)
157 .unwrap_or(0);
158 // 1 = infected, 2 = suspicious in MetaDefender's enum. 0 = clean.
159 if detected == 1 || detected == 2 {
160 let threat = info
161 .get("threat_found")
162 .and_then(|v| v.as_str())
163 .unwrap_or("malicious");
164 threats.push(format!("{engine}: {threat}"));
165 }
166 }
167 }
168 // Cap threat list length to keep the detail string sane.
169 threats.truncate(3);
170 let threats_str = if threats.is_empty() {
171 format!("{total_detected}/{total_avs} engines flagged")
172 } else {
173 format!(
174 "{}/{} engines flagged, {}",
175 total_detected,
176 total_avs,
177 threats.join(", ")
178 )
179 };
180 LayerResult {
181 layer: "metadefender",
182 verdict: LayerVerdict::Fail,
183 detail: Some(threats_str),
184 }
185 }
186
187 #[cfg(test)]
188 mod tests {
189 use super::*;
190 use serde_json::json;
191
192 #[tokio::test]
193 async fn no_api_key_returns_skip() {
194 let r = check_metadefender(&"0".repeat(64), None).await;
195 assert_eq!(r.verdict, LayerVerdict::Skip);
196 assert!(r.detail.unwrap().contains("No MetaDefender"));
197 }
198
199 #[test]
200 fn no_scan_results_field_passes_as_unknown() {
201 let body = json!({});
202 let r = parse_metadefender_response(&body);
203 assert_eq!(r.verdict, LayerVerdict::Pass);
204 assert!(r.detail.unwrap().contains("unknown"));
205 }
206
207 #[test]
208 fn zero_detections_passes_with_count() {
209 let body = json!({
210 "scan_results": {
211 "total_detected_avs": 0,
212 "total_avs": 30,
213 "scan_details": {}
214 }
215 });
216 let r = parse_metadefender_response(&body);
217 assert_eq!(r.verdict, LayerVerdict::Pass);
218 let d = r.detail.unwrap();
219 assert!(d.contains("0/30"));
220 }
221
222 #[test]
223 fn detected_yields_fail_with_engines() {
224 let body = json!({
225 "scan_results": {
226 "total_detected_avs": 2,
227 "total_avs": 30,
228 "scan_details": {
229 "AVG": {"scan_result_i": 1, "threat_found": "Trojan.Generic"},
230 "Bitdefender": {"scan_result_i": 1, "threat_found": "Trojan.Emotet"},
231 "Avast": {"scan_result_i": 0, "threat_found": ""},
232 }
233 }
234 });
235 let r = parse_metadefender_response(&body);
236 assert_eq!(r.verdict, LayerVerdict::Fail);
237 let d = r.detail.unwrap();
238 assert!(d.contains("2/30"));
239 // At least one of the two infected engines surfaced.
240 assert!(d.contains("Trojan.") || d.contains("AVG") || d.contains("Bitdefender"));
241 }
242
243 #[test]
244 fn suspicious_status_also_counts_as_fail() {
245 let body = json!({
246 "scan_results": {
247 "total_detected_avs": 1,
248 "total_avs": 30,
249 "scan_details": {
250 "EngineX": {"scan_result_i": 2, "threat_found": "Suspicious.Generic"},
251 }
252 }
253 });
254 let r = parse_metadefender_response(&body);
255 assert_eq!(r.verdict, LayerVerdict::Fail);
256 assert!(r.detail.unwrap().contains("Suspicious.Generic"));
257 }
258
259 #[test]
260 fn detected_without_threat_label_still_fails() {
261 let body = json!({
262 "scan_results": {
263 "total_detected_avs": 1,
264 "total_avs": 30,
265 "scan_details": {
266 "EngineX": {"scan_result_i": 1},
267 }
268 }
269 });
270 let r = parse_metadefender_response(&body);
271 assert_eq!(r.verdict, LayerVerdict::Fail);
272 }
273 }
274