//! Layer 9: MetaDefender Cloud hash reputation (second opinion). //! //! Hash-lookup only — no file uploaded. Free tier from OPSWAT: 4,000 //! reputation queries/day, 24 requests/min (per their docs). Commercial use //! is licensed under the free tier, unlike VirusTotal's free tier which //! forbids it. //! //! **Gating: second-opinion only.** This layer does NOT run on every upload. //! The pipeline aggregator invokes it only when another layer flagged the //! file as suspicious (YARA hit, ClamAV warning, MalwareBazaar match). On //! a busy day we don't want to spend our quota on uncontroversial uploads. //! See `ScanPipeline::scan` where the gating happens. //! //! Verdict semantics: //! - `Pass` — MetaDefender returns clean across all engines, OR has no //! record of the hash (a clean engine consensus + an absent hash both //! weaken suspicion from the prior layer). //! - `Fail` — at least one engine in MetaDefender's array flagged the hash. //! The detail names the threat label and the engines that hit. //! - `Skip` — no API key configured, OR not invoked (no prior suspicion). //! - `Error` — network failure, rate-limit, auth failure. Fail-open by //! policy; the dashboard surfaces health. use std::time::Duration; use crate::constants; use super::{ErrorPolicy, LayerResult, LayerVerdict}; pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; const API_URL_PREFIX: &str = "https://api.metadefender.com/v4/hash/"; /// Query MetaDefender Cloud for a hash's multi-engine verdict. Returns the /// layer's `Skip` if no API key is configured (the gating decision lives in /// the caller — we only short-circuit on missing config here). pub async fn check_metadefender(sha256: &str, api_key: Option<&str>) -> LayerResult { let Some(key) = api_key else { return LayerResult { layer: "metadefender", verdict: LayerVerdict::Skip, detail: Some("No MetaDefender API key configured".to_string()), }; }; let timeout = Duration::from_secs(constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS); match tokio::time::timeout(timeout, query_hash(sha256, key)).await { Ok(Ok(result)) => result, Ok(Err(e)) => LayerResult { layer: "metadefender", verdict: LayerVerdict::Error, detail: Some(format!("MetaDefender error: {e}")), }, Err(_) => LayerResult { layer: "metadefender", verdict: LayerVerdict::Error, detail: Some("MetaDefender lookup timed out".to_string()), }, } } async fn query_hash(sha256: &str, api_key: &str) -> Result { static CLIENT: std::sync::LazyLock = std::sync::LazyLock::new(reqwest::Client::new); let client = &*CLIENT; let url = format!("{API_URL_PREFIX}{sha256}"); let response = client .get(&url) .header("apikey", api_key) .send() .await .map_err(|e| format!("HTTP request failed: {e}"))?; let status = response.status(); // 404 means "no record of this hash" — that's a Pass for our purposes // (absent + the prior layer's suspicion doesn't escalate). if status.as_u16() == 404 { return Ok(LayerResult { layer: "metadefender", verdict: LayerVerdict::Pass, detail: Some("hash unknown to MetaDefender".to_string()), }); } if status.as_u16() == 401 || status.as_u16() == 403 { return Ok(LayerResult { layer: "metadefender", verdict: LayerVerdict::Error, detail: Some(format!("MetaDefender auth: HTTP {}", status.as_u16())), }); } if status.as_u16() == 429 { return Ok(LayerResult { layer: "metadefender", verdict: LayerVerdict::Error, detail: Some("MetaDefender rate-limited".to_string()), }); } if !status.is_success() { return Err(format!("HTTP {} from MetaDefender", status.as_u16())); } let body: serde_json::Value = response .json() .await .map_err(|e| format!("Failed to parse response: {e}"))?; Ok(parse_metadefender_response(&body)) } /// Parse MetaDefender's `/v4/hash/` response. Extracted for testability. fn parse_metadefender_response(body: &serde_json::Value) -> LayerResult { let scan_results = body.get("scan_results"); let Some(scan_results) = scan_results else { return LayerResult { layer: "metadefender", verdict: LayerVerdict::Pass, detail: Some("hash unknown to MetaDefender".to_string()), }; }; let total_detected = scan_results .get("total_detected_avs") .and_then(|v| v.as_i64()) .unwrap_or(0); let total_avs = scan_results .get("total_avs") .and_then(|v| v.as_i64()) .unwrap_or(0); if total_detected == 0 { return LayerResult { layer: "metadefender", verdict: LayerVerdict::Pass, detail: Some(format!("clean ({}/{} engines)", total_detected, total_avs)), }; } // Identify which engines hit and what threat label they reported. let mut threats: Vec = Vec::new(); if let Some(details) = scan_results.get("scan_details").and_then(|d| d.as_object()) { for (engine, info) in details { let detected = info.get("scan_result_i") .and_then(|v| v.as_i64()) .unwrap_or(0); // 1 = infected, 2 = suspicious in MetaDefender's enum. 0 = clean. if detected == 1 || detected == 2 { let threat = info.get("threat_found") .and_then(|v| v.as_str()) .unwrap_or("malicious"); threats.push(format!("{engine}: {threat}")); } } } // Cap threat list length to keep the detail string sane. threats.truncate(3); let threats_str = if threats.is_empty() { format!("{}/{} engines flagged", total_detected, total_avs) } else { format!("{}/{} engines flagged — {}", total_detected, total_avs, threats.join(", ")) }; LayerResult { layer: "metadefender", verdict: LayerVerdict::Fail, detail: Some(threats_str), } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[tokio::test] async fn no_api_key_returns_skip() { let r = check_metadefender(&"0".repeat(64), None).await; assert_eq!(r.verdict, LayerVerdict::Skip); assert!(r.detail.unwrap().contains("No MetaDefender")); } #[test] fn no_scan_results_field_passes_as_unknown() { let body = json!({}); let r = parse_metadefender_response(&body); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("unknown")); } #[test] fn zero_detections_passes_with_count() { let body = json!({ "scan_results": { "total_detected_avs": 0, "total_avs": 30, "scan_details": {} } }); let r = parse_metadefender_response(&body); assert_eq!(r.verdict, LayerVerdict::Pass); let d = r.detail.unwrap(); assert!(d.contains("0/30")); } #[test] fn detected_yields_fail_with_engines() { let body = json!({ "scan_results": { "total_detected_avs": 2, "total_avs": 30, "scan_details": { "AVG": {"scan_result_i": 1, "threat_found": "Trojan.Generic"}, "Bitdefender": {"scan_result_i": 1, "threat_found": "Trojan.Emotet"}, "Avast": {"scan_result_i": 0, "threat_found": ""}, } } }); let r = parse_metadefender_response(&body); assert_eq!(r.verdict, LayerVerdict::Fail); let d = r.detail.unwrap(); assert!(d.contains("2/30")); // At least one of the two infected engines surfaced. assert!(d.contains("Trojan.") || d.contains("AVG") || d.contains("Bitdefender")); } #[test] fn suspicious_status_also_counts_as_fail() { let body = json!({ "scan_results": { "total_detected_avs": 1, "total_avs": 30, "scan_details": { "EngineX": {"scan_result_i": 2, "threat_found": "Suspicious.Generic"}, } } }); let r = parse_metadefender_response(&body); assert_eq!(r.verdict, LayerVerdict::Fail); assert!(r.detail.unwrap().contains("Suspicious.Generic")); } #[test] fn detected_without_threat_label_still_fails() { let body = json!({ "scan_results": { "total_detected_avs": 1, "total_avs": 30, "scan_details": { "EngineX": {"scan_result_i": 1}, } } }); let r = parse_metadefender_response(&body); assert_eq!(r.verdict, LayerVerdict::Fail); } }