Skip to main content

max / makenotwork

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