Skip to main content

max / makenotwork

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