| 153 |
153 |
|
let response_str = String::from_utf8_lossy(&response);
|
| 154 |
154 |
|
let response_str = response_str.trim_end_matches('\0').trim();
|
| 155 |
155 |
|
|
| 156 |
|
- |
Ok(parse_clamav_response(response_str))
|
|
156 |
+ |
Ok(clamav_layer_result(response_str))
|
| 157 |
157 |
|
}
|
| 158 |
158 |
|
|
| 159 |
159 |
|
async fn scan_instream_streaming<R>(
|
| 221 |
221 |
|
let response_str = String::from_utf8_lossy(&response);
|
| 222 |
222 |
|
let response_str = response_str.trim_end_matches('\0').trim();
|
| 223 |
223 |
|
|
| 224 |
|
- |
Ok(parse_clamav_response(response_str))
|
|
224 |
+ |
Ok(clamav_layer_result(response_str))
|
| 225 |
225 |
|
}
|
| 226 |
226 |
|
|
| 227 |
|
- |
/// Parse a ClamAV INSTREAM response string into a LayerResult.
|
|
227 |
+ |
/// Classified outcome of a clamd INSTREAM reply we *successfully received*.
|
|
228 |
+ |
///
|
|
229 |
+ |
/// The distinction this enum forces is the whole point of CHRONIC S1's fix:
|
|
230 |
+ |
/// transport failures (unreachable daemon / timeout) are handled in the scan
|
|
231 |
+ |
/// entry points and are legitimately FailOpen — clamd being down must not block
|
|
232 |
+ |
/// every upload. But by the time we are parsing a reply, **clamd is reachable**.
|
|
233 |
+ |
/// An error reply at that point (size/scan-size/recursion limit, or anything we
|
|
234 |
+ |
/// can't parse) means the file was *not cleanly scanned* — a coverage gap that
|
|
235 |
+ |
/// must fail CLOSED, never be conflated into the FailOpen unreachable bucket.
|
|
236 |
+ |
/// Because this is an exhaustive enum mapped at one place
|
|
237 |
+ |
/// ([`clamav_layer_result`]), a future reply kind can't silently fall into the
|
|
238 |
+ |
/// FailOpen path the way the old single `LayerVerdict::Error` return did.
|
|
239 |
+ |
#[derive(Debug, PartialEq, Eq)]
|
|
240 |
+ |
enum ClamavResponse {
|
|
241 |
+ |
Clean,
|
|
242 |
+ |
Infected(String),
|
|
243 |
+ |
/// clamd responded but produced no clean verdict — a limit was hit or the
|
|
244 |
+ |
/// reply was unparseable. The file was not fully scanned ⇒ fail closed.
|
|
245 |
+ |
NotFullyScanned(String),
|
|
246 |
+ |
}
|
|
247 |
+ |
|
|
248 |
+ |
/// Parse a clamd INSTREAM response string into a [`ClamavResponse`].
|
| 228 |
249 |
|
/// Extracted for testability — the socket/IO layer just feeds the string in.
|
| 229 |
250 |
|
///
|
| 230 |
251 |
|
/// ClamAV response format:
|
| 231 |
252 |
|
/// - `"stream: OK"` — clean
|
| 232 |
253 |
|
/// - `"stream: <virus_name> FOUND"` — infected
|
| 233 |
|
- |
/// - `"stream: <error> ERROR"` — scan error
|
| 234 |
|
- |
fn parse_clamav_response(response: &str) -> LayerResult {
|
|
254 |
+ |
/// - `"stream: <error> ERROR"` (e.g. `"INSTREAM size limit exceeded"`),
|
|
255 |
+ |
/// empty, or anything else — clamd is reachable but did not fully scan.
|
|
256 |
+ |
fn parse_clamav_response(response: &str) -> ClamavResponse {
|
| 235 |
257 |
|
if response == "stream: OK" {
|
| 236 |
|
- |
LayerResult {
|
| 237 |
|
- |
layer: "clamav",
|
| 238 |
|
- |
verdict: LayerVerdict::Pass,
|
| 239 |
|
- |
detail: None,
|
| 240 |
|
- |
}
|
|
258 |
+ |
ClamavResponse::Clean
|
| 241 |
259 |
|
} else if response.ends_with("FOUND") {
|
| 242 |
260 |
|
// Extract virus name: "stream: Eicar-Signature FOUND" → "Eicar-Signature"
|
| 243 |
261 |
|
let virus_name = response
|
| 245 |
263 |
|
.unwrap_or(response)
|
| 246 |
264 |
|
.strip_suffix(" FOUND")
|
| 247 |
265 |
|
.unwrap_or(response);
|
|
266 |
+ |
ClamavResponse::Infected(virus_name.to_string())
|
|
267 |
+ |
} else {
|
|
268 |
+ |
ClamavResponse::NotFullyScanned(response.to_string())
|
|
269 |
+ |
}
|
|
270 |
+ |
}
|
| 248 |
271 |
|
|
| 249 |
|
- |
LayerResult {
|
|
272 |
+ |
/// Map a *received* clamd reply to a [`LayerResult`]. A `NotFullyScanned`
|
|
273 |
+ |
/// outcome is emitted under the dedicated `clamav_incomplete` layer, whose
|
|
274 |
+ |
/// `error_policy_for` entry is **FailClosed**, so an un-scanned file is held for
|
|
275 |
+ |
/// review. A genuinely unreachable daemon / timeout is reported separately in
|
|
276 |
+ |
/// the scan entry points under the `clamav` layer (FailOpen). Routing the two
|
|
277 |
+ |
/// error kinds to different layer identities is the same mechanism the
|
|
278 |
+ |
/// `scan_panic` / `scan_size_limit` pseudo-layers already use to pick a policy.
|
|
279 |
+ |
fn clamav_layer_result(response: &str) -> LayerResult {
|
|
280 |
+ |
match parse_clamav_response(response) {
|
|
281 |
+ |
ClamavResponse::Clean => LayerResult {
|
| 250 |
282 |
|
layer: "clamav",
|
| 251 |
|
- |
verdict: LayerVerdict::Fail,
|
| 252 |
|
- |
detail: Some(format!("ClamAV detection: {}", virus_name)),
|
| 253 |
|
- |
}
|
| 254 |
|
- |
} else {
|
| 255 |
|
- |
LayerResult {
|
|
283 |
+ |
verdict: LayerVerdict::Pass,
|
|
284 |
+ |
detail: None,
|
|
285 |
+ |
},
|
|
286 |
+ |
ClamavResponse::Infected(name) => LayerResult {
|
| 256 |
287 |
|
layer: "clamav",
|
|
288 |
+ |
verdict: LayerVerdict::Fail,
|
|
289 |
+ |
detail: Some(format!("ClamAV detection: {name}")),
|
|
290 |
+ |
},
|
|
291 |
+ |
ClamavResponse::NotFullyScanned(resp) => LayerResult {
|
|
292 |
+ |
layer: "clamav_incomplete",
|
| 257 |
293 |
|
verdict: LayerVerdict::Error,
|
| 258 |
|
- |
detail: Some(format!("Unexpected ClamAV response: {}", response)),
|
| 259 |
|
- |
}
|
|
294 |
+ |
detail: Some(format!("ClamAV did not fully scan (held for review): {resp}")),
|
|
295 |
+ |
},
|
| 260 |
296 |
|
}
|
| 261 |
297 |
|
}
|
| 262 |
298 |
|
|
| 268 |
304 |
|
|
| 269 |
305 |
|
#[test]
|
| 270 |
306 |
|
fn clean_response_passes() {
|
| 271 |
|
- |
let result = parse_clamav_response("stream: OK");
|
|
307 |
+ |
assert_eq!(parse_clamav_response("stream: OK"), ClamavResponse::Clean);
|
|
308 |
+ |
let result = clamav_layer_result("stream: OK");
|
| 272 |
309 |
|
assert_eq!(result.verdict, LayerVerdict::Pass);
|
|
310 |
+ |
assert_eq!(result.layer, "clamav");
|
| 273 |
311 |
|
assert!(result.detail.is_none());
|
| 274 |
312 |
|
}
|
| 275 |
313 |
|
|
| 277 |
315 |
|
|
| 278 |
316 |
|
#[test]
|
| 279 |
317 |
|
fn eicar_detection_fails() {
|
| 280 |
|
- |
let result = parse_clamav_response("stream: Eicar-Signature FOUND");
|
|
318 |
+ |
assert_eq!(
|
|
319 |
+ |
parse_clamav_response("stream: Eicar-Signature FOUND"),
|
|
320 |
+ |
ClamavResponse::Infected("Eicar-Signature".to_string())
|
|
321 |
+ |
);
|
|
322 |
+ |
let result = clamav_layer_result("stream: Eicar-Signature FOUND");
|
| 281 |
323 |
|
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 282 |
|
- |
let detail = result.detail.unwrap();
|
| 283 |
|
- |
assert!(detail.contains("Eicar-Signature"));
|
|
324 |
+ |
assert_eq!(result.layer, "clamav");
|
|
325 |
+ |
assert!(result.detail.unwrap().contains("Eicar-Signature"));
|
| 284 |
326 |
|
}
|
| 285 |
327 |
|
|
| 286 |
328 |
|
#[test]
|
| 287 |
329 |
|
fn complex_virus_name_extracted() {
|
| 288 |
|
- |
let result = parse_clamav_response("stream: Win.Test.EICAR_HDB-1 FOUND");
|
|
330 |
+ |
let result = clamav_layer_result("stream: Win.Test.EICAR_HDB-1 FOUND");
|
| 289 |
331 |
|
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 290 |
|
- |
let detail = result.detail.unwrap();
|
| 291 |
|
- |
assert!(detail.contains("Win.Test.EICAR_HDB-1"));
|
|
332 |
+ |
assert!(result.detail.unwrap().contains("Win.Test.EICAR_HDB-1"));
|
| 292 |
333 |
|
}
|
| 293 |
334 |
|
|
| 294 |
335 |
|
#[test]
|
| 295 |
336 |
|
fn trojan_detection_fails() {
|
| 296 |
|
- |
let result = parse_clamav_response("stream: Win.Trojan.Agent-123456 FOUND");
|
|
337 |
+ |
let result = clamav_layer_result("stream: Win.Trojan.Agent-123456 FOUND");
|
| 297 |
338 |
|
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 298 |
|
- |
let detail = result.detail.unwrap();
|
| 299 |
|
- |
assert!(detail.contains("Win.Trojan.Agent-123456"));
|
|
339 |
+ |
assert!(result.detail.unwrap().contains("Win.Trojan.Agent-123456"));
|
| 300 |
340 |
|
}
|
| 301 |
341 |
|
|
| 302 |
|
- |
// -- Error responses --
|
|
342 |
+ |
// -- Not-fully-scanned responses (clamd reachable, no clean verdict) --
|
|
343 |
+ |
//
|
|
344 |
+ |
// CHRONIC S1: every one of these must FAIL CLOSED, not FailOpen. They are
|
|
345 |
+ |
// emitted under the `clamav_incomplete` layer (FailClosed in
|
|
346 |
+ |
// `error_policy_for`), distinct from the FailOpen `clamav` layer used for an
|
|
347 |
+ |
// unreachable daemon.
|
| 303 |
348 |
|
|
| 304 |
349 |
|
#[test]
|
| 305 |
|
- |
fn empty_response_is_error() {
|
| 306 |
|
- |
let result = parse_clamav_response("");
|
| 307 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Error);
|
|
350 |
+ |
fn empty_response_is_not_fully_scanned() {
|
|
351 |
+ |
assert_eq!(
|
|
352 |
+ |
parse_clamav_response(""),
|
|
353 |
+ |
ClamavResponse::NotFullyScanned(String::new())
|
|
354 |
+ |
);
|
|
355 |
+ |
assert_eq!(clamav_layer_result("").layer, "clamav_incomplete");
|
|
356 |
+ |
assert_eq!(clamav_layer_result("").verdict, LayerVerdict::Error);
|
| 308 |
357 |
|
}
|
| 309 |
358 |
|
|
| 310 |
359 |
|
#[test]
|
| 311 |
|
- |
fn garbage_response_is_error() {
|
| 312 |
|
- |
let result = parse_clamav_response("this is not a valid response");
|
|
360 |
+ |
fn garbage_response_is_not_fully_scanned() {
|
|
361 |
+ |
let result = clamav_layer_result("this is not a valid response");
|
|
362 |
+ |
assert_eq!(result.layer, "clamav_incomplete");
|
| 313 |
363 |
|
assert_eq!(result.verdict, LayerVerdict::Error);
|
| 314 |
|
- |
assert!(result.detail.unwrap().contains("Unexpected ClamAV response"));
|
| 315 |
364 |
|
}
|
| 316 |
365 |
|
|
| 317 |
366 |
|
#[test]
|
| 318 |
|
- |
fn error_keyword_in_response_is_error() {
|
| 319 |
|
- |
// ClamAV may respond with "stream: <message> ERROR" for scan errors
|
| 320 |
|
- |
let result = parse_clamav_response("stream: INSTREAM size limit exceeded ERROR");
|
|
367 |
+ |
fn size_limit_error_fails_closed_not_open() {
|
|
368 |
+ |
// The regression that defined CHRONIC S1: a payload sized past clamd's
|
|
369 |
+ |
// StreamMaxLength yields this reply on a HEALTHY clamd. It must route to
|
|
370 |
+ |
// the FailClosed `clamav_incomplete` layer, never the FailOpen `clamav`
|
|
371 |
+ |
// layer (which would skip → Clean).
|
|
372 |
+ |
let response = "stream: INSTREAM size limit exceeded ERROR";
|
|
373 |
+ |
assert!(matches!(
|
|
374 |
+ |
parse_clamav_response(response),
|
|
375 |
+ |
ClamavResponse::NotFullyScanned(_)
|
|
376 |
+ |
));
|
|
377 |
+ |
let result = clamav_layer_result(response);
|
|
378 |
+ |
assert_eq!(result.layer, "clamav_incomplete");
|
| 321 |
379 |
|
assert_eq!(result.verdict, LayerVerdict::Error);
|
| 322 |
380 |
|
}
|
| 323 |
381 |
|
}
|