Skip to main content

max / makenotwork

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