//! Layer 6: MalwareBazaar SHA-256 hash lookup. //! //! Sends only the file's SHA-256 hash to the MalwareBazaar API — the file //! itself is never uploaded. Privacy-safe: no file content leaves the server. //! //! As of 2024+, abuse.ch requires a free `Auth-Key` header on all queries. //! Register at . Without a key the layer returns //! `Skip` (fail-open by policy means a missing key never holds an upload), //! and the dashboard surfaces it as degraded. use std::time::Duration; use crate::constants; use super::{ErrorPolicy, LayerResult, LayerVerdict}; /// External third-party network layer. The MalwareBazaar API may change /// shape, rate-limit, require auth, or be unreachable. None of those are /// reasons to hold every upload for review. Fail open; PoM surfaces health. pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; const MALWAREBAZAAR_API_URL: &str = "https://mb-api.abuse.ch/api/v1/"; /// Check a file's SHA-256 hash against MalwareBazaar's known-malware database. /// /// `auth_key` is the shared abuse.ch Auth-Key. When `None`, the layer /// short-circuits to `Skip` — no network call. pub async fn check_malwarebazaar(sha256: &str, auth_key: Option<&str>) -> LayerResult { let Some(key) = auth_key else { return LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Skip, detail: Some("No abuse.ch Auth-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: "malwarebazaar", verdict: LayerVerdict::Error, detail: Some(format!("MalwareBazaar error: {}", e)), }, Err(_) => LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Error, detail: Some("MalwareBazaar lookup timed out".to_string()), }, } } async fn query_hash(sha256: &str, auth_key: &str) -> Result { static CLIENT: std::sync::LazyLock = std::sync::LazyLock::new(reqwest::Client::new); let client = &*CLIENT; let params = [("query", "get_info"), ("hash", sha256)]; let response = client .post(MALWAREBAZAAR_API_URL) .header("Auth-Key", auth_key) .form(¶ms) .send() .await .map_err(|e| format!("HTTP request failed: {}", e))?; let status = response.status(); if !status.is_success() { return Ok(LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Error, detail: Some(format!("HTTP {} from MalwareBazaar", status.as_u16())), }); } let body: serde_json::Value = response .json() .await .map_err(|e| format!("Failed to parse response: {}", e))?; Ok(parse_malwarebazaar_response(&body)) } /// Parse a MalwareBazaar JSON response into a LayerResult. /// Extracted for testability — the HTTP layer just feeds the parsed JSON in. fn parse_malwarebazaar_response(body: &serde_json::Value) -> LayerResult { let query_status = body .get("query_status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); match query_status { "hash_not_found" => LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Pass, detail: None, }, "ok" => { let signature = body .get("data") .and_then(|d| d.get(0)) .and_then(|entry| entry.get("signature")) .and_then(|s| s.as_str()) .unwrap_or("unknown"); LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Fail, detail: Some(format!("Known malware: {}", signature)), } } // Authentication failure modes from abuse.ch. Treat as Error so PoM // alerts the operator that the key needs attention. "unauthorized" | "key_required" | "key_invalid" => LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Error, detail: Some(format!("abuse.ch auth: {}", query_status)), }, other => LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Error, detail: Some(format!("Unexpected query_status: {}", other)), }, } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[tokio::test] async fn no_auth_key_returns_skip() { let result = check_malwarebazaar("0".repeat(64).as_str(), None).await; assert_eq!(result.verdict, LayerVerdict::Skip); assert!(result.detail.unwrap().contains("Auth-Key")); } #[test] fn hash_not_found_passes() { let body = json!({"query_status": "hash_not_found"}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Pass); assert!(result.detail.is_none()); } #[test] fn known_malware_with_signature_fails() { let body = json!({ "query_status": "ok", "data": [{ "sha256_hash": "abc123", "signature": "Emotet", "file_type": "exe" }] }); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Fail); let detail = result.detail.unwrap(); assert!(detail.contains("Emotet")); } #[test] fn known_malware_without_signature_shows_unknown() { let body = json!({ "query_status": "ok", "data": [{"sha256_hash": "abc123"}] }); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("unknown")); } #[test] fn known_malware_empty_data_array() { let body = json!({"query_status": "ok", "data": []}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Fail); } #[test] fn known_malware_missing_data_field() { let body = json!({"query_status": "ok"}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Fail); } #[test] fn illegal_search_term_is_error() { let body = json!({"query_status": "illegal_search_term"}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Error); assert!(result.detail.unwrap().contains("illegal_search_term")); } #[test] fn unauthorized_is_error() { let body = json!({"query_status": "unauthorized"}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Error); assert!(result.detail.unwrap().contains("abuse.ch auth")); } #[test] fn key_required_is_error() { let body = json!({"query_status": "key_required"}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Error); assert!(result.detail.unwrap().contains("abuse.ch auth")); } #[test] fn missing_query_status_is_error() { // This is the literal regression of 2026-05-10: an authenticated // response shape changed and the parser fell through to a generic // "unknown" error. Now that error has explicit handling. let body = json!({"something_else": "value"}); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Error); assert!(result.detail.unwrap().contains("unknown")); } #[test] fn null_body_is_error() { let body = json!(null); let result = parse_malwarebazaar_response(&body); assert_eq!(result.verdict, LayerVerdict::Error); } }