Skip to main content

max / makenotwork

8.4 KB · 243 lines History Blame Raw
1 //! Layer 6: MalwareBazaar SHA-256 hash lookup.
2 //!
3 //! Sends only the file's SHA-256 hash to the MalwareBazaar API, the file
4 //! itself is never uploaded. Privacy-safe: no file content leaves the server.
5 //!
6 //! As of 2024+, abuse.ch requires a free `Auth-Key` header on all queries.
7 //! Register at <https://auth.abuse.ch>. Without a key the layer returns
8 //! `Skip` (fail-open by policy means a missing key never holds an upload),
9 //! and the dashboard surfaces it as degraded.
10
11 use std::time::Duration;
12
13 use crate::constants;
14
15 use super::{ErrorPolicy, LayerResult, LayerVerdict};
16
17 /// External third-party network layer. The MalwareBazaar API may change
18 /// shape, rate-limit, require auth, or be unreachable. None of those are
19 /// reasons to hold every upload for review. Fail open; PoM surfaces health.
20 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen;
21
22 const MALWAREBAZAAR_API_URL: &str = "https://mb-api.abuse.ch/api/v1/";
23
24 /// Check a file's SHA-256 hash against MalwareBazaar's known-malware database.
25 ///
26 /// `auth_key` is the shared abuse.ch Auth-Key. When `None`, the layer
27 /// short-circuits to `Skip`, no network call.
28 pub async fn check_malwarebazaar(sha256: &str, auth_key: Option<&str>) -> LayerResult {
29 let Some(key) = auth_key else {
30 return LayerResult {
31 layer: "malwarebazaar",
32 verdict: LayerVerdict::Skip,
33 detail: Some("No abuse.ch Auth-Key configured".to_string()),
34 };
35 };
36
37 let timeout = Duration::from_secs(constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS);
38
39 match tokio::time::timeout(timeout, query_hash(sha256, key)).await {
40 Ok(Ok(result)) => result,
41 Ok(Err(e)) => LayerResult {
42 layer: "malwarebazaar",
43 verdict: LayerVerdict::Error,
44 detail: Some(format!("MalwareBazaar error: {e}")),
45 },
46 Err(_) => LayerResult {
47 layer: "malwarebazaar",
48 verdict: LayerVerdict::Error,
49 detail: Some("MalwareBazaar lookup timed out".to_string()),
50 },
51 }
52 }
53
54 async fn query_hash(sha256: &str, auth_key: &str) -> Result<LayerResult, String> {
55 // Explicit request + connect timeouts (Perf-S2); a default client has neither.
56 static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
57 crate::crypto::install_default_crypto_provider();
58 reqwest::Client::builder()
59 .timeout(std::time::Duration::from_secs(
60 crate::constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS,
61 ))
62 .connect_timeout(std::time::Duration::from_secs(
63 crate::constants::SCAN_HTTP_CONNECT_TIMEOUT_SECS,
64 ))
65 .build()
66 .unwrap_or_default()
67 });
68
69 let client = &*CLIENT;
70 let params = [("query", "get_info"), ("hash", sha256)];
71
72 let response = client
73 .post(MALWAREBAZAAR_API_URL)
74 .header("Auth-Key", auth_key)
75 .form(&params)
76 .send()
77 .await
78 .map_err(|e| format!("HTTP request failed: {e}"))?;
79
80 let status = response.status();
81 if !status.is_success() {
82 return Ok(LayerResult {
83 layer: "malwarebazaar",
84 verdict: LayerVerdict::Error,
85 detail: Some(format!("HTTP {} from MalwareBazaar", status.as_u16())),
86 });
87 }
88
89 let body: serde_json::Value = response
90 .json()
91 .await
92 .map_err(|e| format!("Failed to parse response: {e}"))?;
93
94 Ok(parse_malwarebazaar_response(&body))
95 }
96
97 /// Parse a MalwareBazaar JSON response into a LayerResult.
98 /// Extracted for testability, the HTTP layer just feeds the parsed JSON in.
99 fn parse_malwarebazaar_response(body: &serde_json::Value) -> LayerResult {
100 let query_status = body
101 .get("query_status")
102 .and_then(|v| v.as_str())
103 .unwrap_or("unknown");
104
105 match query_status {
106 "hash_not_found" => LayerResult {
107 layer: "malwarebazaar",
108 verdict: LayerVerdict::Pass,
109 detail: None,
110 },
111 "ok" => {
112 let signature = body
113 .get("data")
114 .and_then(|d| d.get(0))
115 .and_then(|entry| entry.get("signature"))
116 .and_then(|s| s.as_str())
117 .unwrap_or("unknown");
118
119 LayerResult {
120 layer: "malwarebazaar",
121 verdict: LayerVerdict::Fail,
122 detail: Some(format!("Known malware: {signature}")),
123 }
124 }
125 // Authentication failure modes from abuse.ch. Treat as Error so PoM
126 // alerts the operator that the key needs attention.
127 "unauthorized" | "key_required" | "key_invalid" => LayerResult {
128 layer: "malwarebazaar",
129 verdict: LayerVerdict::Error,
130 detail: Some(format!("abuse.ch auth: {query_status}")),
131 },
132 other => LayerResult {
133 layer: "malwarebazaar",
134 verdict: LayerVerdict::Error,
135 detail: Some(format!("Unexpected query_status: {other}")),
136 },
137 }
138 }
139
140 #[cfg(test)]
141 mod tests {
142 use super::*;
143 use serde_json::json;
144
145 #[tokio::test]
146 async fn no_auth_key_returns_skip() {
147 let result = check_malwarebazaar("0".repeat(64).as_str(), None).await;
148 assert_eq!(result.verdict, LayerVerdict::Skip);
149 assert!(result.detail.unwrap().contains("Auth-Key"));
150 }
151
152 #[test]
153 fn hash_not_found_passes() {
154 let body = json!({"query_status": "hash_not_found"});
155 let result = parse_malwarebazaar_response(&body);
156 assert_eq!(result.verdict, LayerVerdict::Pass);
157 assert!(result.detail.is_none());
158 }
159
160 #[test]
161 fn known_malware_with_signature_fails() {
162 let body = json!({
163 "query_status": "ok",
164 "data": [{
165 "sha256_hash": "abc123",
166 "signature": "Emotet",
167 "file_type": "exe"
168 }]
169 });
170 let result = parse_malwarebazaar_response(&body);
171 assert_eq!(result.verdict, LayerVerdict::Fail);
172 let detail = result.detail.unwrap();
173 assert!(detail.contains("Emotet"));
174 }
175
176 #[test]
177 fn known_malware_without_signature_shows_unknown() {
178 let body = json!({
179 "query_status": "ok",
180 "data": [{"sha256_hash": "abc123"}]
181 });
182 let result = parse_malwarebazaar_response(&body);
183 assert_eq!(result.verdict, LayerVerdict::Fail);
184 assert!(result.detail.unwrap().contains("unknown"));
185 }
186
187 #[test]
188 fn known_malware_empty_data_array() {
189 let body = json!({"query_status": "ok", "data": []});
190 let result = parse_malwarebazaar_response(&body);
191 assert_eq!(result.verdict, LayerVerdict::Fail);
192 }
193
194 #[test]
195 fn known_malware_missing_data_field() {
196 let body = json!({"query_status": "ok"});
197 let result = parse_malwarebazaar_response(&body);
198 assert_eq!(result.verdict, LayerVerdict::Fail);
199 }
200
201 #[test]
202 fn illegal_search_term_is_error() {
203 let body = json!({"query_status": "illegal_search_term"});
204 let result = parse_malwarebazaar_response(&body);
205 assert_eq!(result.verdict, LayerVerdict::Error);
206 assert!(result.detail.unwrap().contains("illegal_search_term"));
207 }
208
209 #[test]
210 fn unauthorized_is_error() {
211 let body = json!({"query_status": "unauthorized"});
212 let result = parse_malwarebazaar_response(&body);
213 assert_eq!(result.verdict, LayerVerdict::Error);
214 assert!(result.detail.unwrap().contains("abuse.ch auth"));
215 }
216
217 #[test]
218 fn key_required_is_error() {
219 let body = json!({"query_status": "key_required"});
220 let result = parse_malwarebazaar_response(&body);
221 assert_eq!(result.verdict, LayerVerdict::Error);
222 assert!(result.detail.unwrap().contains("abuse.ch auth"));
223 }
224
225 #[test]
226 fn missing_query_status_is_error() {
227 // This is the literal regression of 2026-05-10: an authenticated
228 // response shape changed and the parser fell through to a generic
229 // "unknown" error. Now that error has explicit handling.
230 let body = json!({"something_else": "value"});
231 let result = parse_malwarebazaar_response(&body);
232 assert_eq!(result.verdict, LayerVerdict::Error);
233 assert!(result.detail.unwrap().contains("unknown"));
234 }
235
236 #[test]
237 fn null_body_is_error() {
238 let body = json!(null);
239 let result = parse_malwarebazaar_response(&body);
240 assert_eq!(result.verdict, LayerVerdict::Error);
241 }
242 }
243