Skip to main content

max / makenotwork

8.3 KB · 242 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 reqwest::Client::builder()
58 .timeout(std::time::Duration::from_secs(
59 crate::constants::SCAN_MALWAREBAZAAR_TIMEOUT_SECS,
60 ))
61 .connect_timeout(std::time::Duration::from_secs(
62 crate::constants::SCAN_HTTP_CONNECT_TIMEOUT_SECS,
63 ))
64 .build()
65 .unwrap_or_default()
66 });
67
68 let client = &*CLIENT;
69 let params = [("query", "get_info"), ("hash", sha256)];
70
71 let response = client
72 .post(MALWAREBAZAAR_API_URL)
73 .header("Auth-Key", auth_key)
74 .form(&params)
75 .send()
76 .await
77 .map_err(|e| format!("HTTP request failed: {e}"))?;
78
79 let status = response.status();
80 if !status.is_success() {
81 return Ok(LayerResult {
82 layer: "malwarebazaar",
83 verdict: LayerVerdict::Error,
84 detail: Some(format!("HTTP {} from MalwareBazaar", status.as_u16())),
85 });
86 }
87
88 let body: serde_json::Value = response
89 .json()
90 .await
91 .map_err(|e| format!("Failed to parse response: {e}"))?;
92
93 Ok(parse_malwarebazaar_response(&body))
94 }
95
96 /// Parse a MalwareBazaar JSON response into a LayerResult.
97 /// Extracted for testability, the HTTP layer just feeds the parsed JSON in.
98 fn parse_malwarebazaar_response(body: &serde_json::Value) -> LayerResult {
99 let query_status = body
100 .get("query_status")
101 .and_then(|v| v.as_str())
102 .unwrap_or("unknown");
103
104 match query_status {
105 "hash_not_found" => LayerResult {
106 layer: "malwarebazaar",
107 verdict: LayerVerdict::Pass,
108 detail: None,
109 },
110 "ok" => {
111 let signature = body
112 .get("data")
113 .and_then(|d| d.get(0))
114 .and_then(|entry| entry.get("signature"))
115 .and_then(|s| s.as_str())
116 .unwrap_or("unknown");
117
118 LayerResult {
119 layer: "malwarebazaar",
120 verdict: LayerVerdict::Fail,
121 detail: Some(format!("Known malware: {signature}")),
122 }
123 }
124 // Authentication failure modes from abuse.ch. Treat as Error so PoM
125 // alerts the operator that the key needs attention.
126 "unauthorized" | "key_required" | "key_invalid" => LayerResult {
127 layer: "malwarebazaar",
128 verdict: LayerVerdict::Error,
129 detail: Some(format!("abuse.ch auth: {query_status}")),
130 },
131 other => LayerResult {
132 layer: "malwarebazaar",
133 verdict: LayerVerdict::Error,
134 detail: Some(format!("Unexpected query_status: {other}")),
135 },
136 }
137 }
138
139 #[cfg(test)]
140 mod tests {
141 use super::*;
142 use serde_json::json;
143
144 #[tokio::test]
145 async fn no_auth_key_returns_skip() {
146 let result = check_malwarebazaar("0".repeat(64).as_str(), None).await;
147 assert_eq!(result.verdict, LayerVerdict::Skip);
148 assert!(result.detail.unwrap().contains("Auth-Key"));
149 }
150
151 #[test]
152 fn hash_not_found_passes() {
153 let body = json!({"query_status": "hash_not_found"});
154 let result = parse_malwarebazaar_response(&body);
155 assert_eq!(result.verdict, LayerVerdict::Pass);
156 assert!(result.detail.is_none());
157 }
158
159 #[test]
160 fn known_malware_with_signature_fails() {
161 let body = json!({
162 "query_status": "ok",
163 "data": [{
164 "sha256_hash": "abc123",
165 "signature": "Emotet",
166 "file_type": "exe"
167 }]
168 });
169 let result = parse_malwarebazaar_response(&body);
170 assert_eq!(result.verdict, LayerVerdict::Fail);
171 let detail = result.detail.unwrap();
172 assert!(detail.contains("Emotet"));
173 }
174
175 #[test]
176 fn known_malware_without_signature_shows_unknown() {
177 let body = json!({
178 "query_status": "ok",
179 "data": [{"sha256_hash": "abc123"}]
180 });
181 let result = parse_malwarebazaar_response(&body);
182 assert_eq!(result.verdict, LayerVerdict::Fail);
183 assert!(result.detail.unwrap().contains("unknown"));
184 }
185
186 #[test]
187 fn known_malware_empty_data_array() {
188 let body = json!({"query_status": "ok", "data": []});
189 let result = parse_malwarebazaar_response(&body);
190 assert_eq!(result.verdict, LayerVerdict::Fail);
191 }
192
193 #[test]
194 fn known_malware_missing_data_field() {
195 let body = json!({"query_status": "ok"});
196 let result = parse_malwarebazaar_response(&body);
197 assert_eq!(result.verdict, LayerVerdict::Fail);
198 }
199
200 #[test]
201 fn illegal_search_term_is_error() {
202 let body = json!({"query_status": "illegal_search_term"});
203 let result = parse_malwarebazaar_response(&body);
204 assert_eq!(result.verdict, LayerVerdict::Error);
205 assert!(result.detail.unwrap().contains("illegal_search_term"));
206 }
207
208 #[test]
209 fn unauthorized_is_error() {
210 let body = json!({"query_status": "unauthorized"});
211 let result = parse_malwarebazaar_response(&body);
212 assert_eq!(result.verdict, LayerVerdict::Error);
213 assert!(result.detail.unwrap().contains("abuse.ch auth"));
214 }
215
216 #[test]
217 fn key_required_is_error() {
218 let body = json!({"query_status": "key_required"});
219 let result = parse_malwarebazaar_response(&body);
220 assert_eq!(result.verdict, LayerVerdict::Error);
221 assert!(result.detail.unwrap().contains("abuse.ch auth"));
222 }
223
224 #[test]
225 fn missing_query_status_is_error() {
226 // This is the literal regression of 2026-05-10: an authenticated
227 // response shape changed and the parser fell through to a generic
228 // "unknown" error. Now that error has explicit handling.
229 let body = json!({"something_else": "value"});
230 let result = parse_malwarebazaar_response(&body);
231 assert_eq!(result.verdict, LayerVerdict::Error);
232 assert!(result.detail.unwrap().contains("unknown"));
233 }
234
235 #[test]
236 fn null_body_is_error() {
237 let body = json!(null);
238 let result = parse_malwarebazaar_response(&body);
239 assert_eq!(result.verdict, LayerVerdict::Error);
240 }
241 }
242