server/inspect/src/malware.rs
//! **The malware hook: optional, pluggable scanners.**
//!
//! A [`MalwareScanner`] scans bytes and says [`ScanOutcome::Clean`],
//! [`ScanOutcome::Detected`], [`ScanOutcome::NotConfigured`] or
//! [`ScanOutcome::Error`]. Two adapters ship in the open core:
//!
//! * [`ClamAv`] — a clamd socket (unix path or `host:port`, the `INSTREAM`
//! protocol) or the `clamscan` binary. ClamAV unpacks archives itself, so it
//! is handed the artifact once.
//! * [`Yara`] — rule files run by the built-in matcher ([`crate::yara_lite`])
//! when they fit its subset, else by the `yara` binary. YARA does not unpack,
//! so the inspector hands it every member as well as the artifact.
//!
//! An engine that is not installed reports "not configured". How that weighs
//! is the scanner's `required` flag: a required scanner that is not configured,
//! or that fails, BLOCKS; an optional one leaves an info or warn finding.
//! Commercial engines are not here (Holger pro).
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{FindingKind, Findings, Severity};
/// What one scan concluded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScanOutcome {
Clean,
/// The signatures or rules that matched.
Detected(Vec<String>),
/// The engine is not installed, or has nothing to run.
NotConfigured(String),
/// The engine is there but the scan failed.
Error(String),
}
/// A pluggable malware scanner.
pub trait MalwareScanner: Send + Sync {
/// The name in findings and evidence: `clamav`, `yara`.
fn name(&self) -> &str;
/// A required scanner that is not configured, or fails, blocks.
fn required(&self) -> bool;
/// Scan one artifact or member. `name` is its file name, for messages.
fn scan(&self, bytes: &[u8], name: &str) -> ScanOutcome;
/// Does it want every member of an archive too, not only the artifact?
fn reads_members(&self) -> bool {
false
}
}
/// The artifact-level scan: every scanner, once.
/// Run the artifact-level scan of `scanners` over `bytes` into `out`. The
/// server calls it with ClamAV alone: clamd reads the bytes in its own sandboxed
/// process, so nothing is unpacked here.
pub fn scan_artifact(
scanners: &[std::sync::Arc<dyn MalwareScanner>],
bytes: &[u8],
name: &str,
out: &mut Findings,
) {
let label = if name.is_empty() {
format!("<{}>", out.format.name())
} else {
name.to_string()
};
for s in scanners {
out.scanners.push(s.name().to_string());
match s.scan(bytes, name) {
ScanOutcome::Clean => {}
ScanOutcome::Detected(sigs) => out.push(
FindingKind::Malware,
Severity::Block,
&label,
format!("{}: {}", s.name(), sigs.join(", ")),
),
ScanOutcome::NotConfigured(why) => {
let sev = if s.required() {
Severity::Block
} else {
Severity::Info
};
let req = if s.required() {
"required scanner"
} else {
"optional scanner"
};
out.push(
FindingKind::ScannerNotConfigured,
sev,
&label,
format!("{req} {} not configured: {why}", s.name()),
);
}
ScanOutcome::Error(e) => {
let sev = if s.required() {
Severity::Block
} else {
Severity::Warn
};
out.push(
FindingKind::ScannerError,
sev,
&label,
format!("{}: {e}", s.name()),
);
}
}
}
}
/// The member-level scan: the scanners that read members, on one member.
/// "Not configured" is reported once, by the artifact-level scan.
pub(crate) fn scan_member(
scanners: &[std::sync::Arc<dyn MalwareScanner>],
bytes: &[u8],
name: &str,
mpath: &str,
out: &mut Findings,
) {
for s in scanners.iter().filter(|s| s.reads_members()) {
match s.scan(bytes, name) {
ScanOutcome::Detected(sigs) => out.push(
FindingKind::Malware,
Severity::Block,
mpath,
format!("{}: {}", s.name(), sigs.join(", ")),
),
ScanOutcome::Error(e) => {
let sev = if s.required() {
Severity::Block
} else {
Severity::Warn
};
out.push(
FindingKind::ScannerError,
sev,
mpath,
format!("{}: {e}", s.name()),
);
}
ScanOutcome::Clean | ScanOutcome::NotConfigured(_) => {}
}
}
}
/// An executable named `cmd` on `PATH`.
pub fn which(cmd: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|d| d.join(cmd))
.find(|p| is_executable(p))
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
p.metadata()
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
/// Run `cmd` with `input` on stdin; (exit code, stdout, stderr).
fn run(mut cmd: Command, input: Option<&[u8]>) -> std::io::Result<(Option<i32>, String, String)> {
cmd.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// A32-14, a process by design: the scanner process itself (see its constructor).
let mut child = cmd.spawn()?;
let writer = match (input, child.stdin.take()) {
(Some(bytes), Some(mut stdin)) => {
let bytes = bytes.to_vec();
// A32-14, stays a task: the scanner's stdin writer, joined after the scan.
Some(std::thread::spawn(move || {
let _ = stdin.write_all(&bytes);
}))
}
_ => None,
};
let out = child.wait_with_output()?;
if let Some(w) = writer {
let _ = w.join();
}
Ok((
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
))
}
// ── ClamAV ──────────────────────────────────────────────────────────────────
/// The `clamav:` block.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ClamAvConfig {
/// A clamd socket: a unix socket path, or `host:port`. Unset: the usual
/// socket paths are tried, then `clamscan` on PATH. Holger's server in a real
/// build takes only the SANDBOXED clamd's unix socket here (no `host:port`, no
/// host clamd, no `clamscan`; `holger-sandbox-15`, lane HOL-NCSC).
pub socket: Option<String>,
/// The `clamscan` binary, used when no socket is set.
pub clamscan: Option<String>,
pub required: bool,
/// Socket read and write timeout.
pub timeout_secs: u64,
}
impl Default for ClamAvConfig {
fn default() -> Self {
Self {
socket: None,
clamscan: None,
required: false,
timeout_secs: 120,
}
}
}
/// Where clamd listens on Debian, Fedora and the upstream default.
pub const CLAMD_SOCKETS: [&str; 4] = [
"/run/clamav/clamd.ctl",
"/var/run/clamav/clamd.ctl",
"/run/clamd.scan/clamd.sock",
"/tmp/clamd.socket",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClamEngine {
#[cfg(unix)]
Unix(PathBuf),
Tcp(String),
Clamscan(PathBuf),
}
/// The ClamAV adapter.
#[derive(Debug, Clone)]
pub struct ClamAv {
cfg: ClamAvConfig,
}
impl ClamAv {
pub fn from_config(cfg: &ClamAvConfig) -> Self {
Self { cfg: cfg.clone() }
}
/// The engine this host has, or why there is none.
pub fn engine(&self) -> Result<ClamEngine, String> {
if let Some(s) = &self.cfg.socket {
if !s.contains('/') && s.contains(':') {
return Ok(ClamEngine::Tcp(s.clone()));
}
#[cfg(unix)]
{
return if Path::new(s).exists() {
Ok(ClamEngine::Unix(PathBuf::from(s)))
} else {
Err(format!(
"clamd socket {s} does not exist (is clamd installed and running?)"
))
};
}
#[cfg(not(unix))]
return Err(format!("unix socket {s} on a host without unix sockets"));
}
if let Some(c) = &self.cfg.clamscan {
return if is_executable(Path::new(c)) {
Ok(ClamEngine::Clamscan(PathBuf::from(c)))
} else {
Err(format!("clamscan {c} is not installed"))
};
}
#[cfg(unix)]
if let Some(p) = CLAMD_SOCKETS.iter().map(Path::new).find(|p| p.exists()) {
return Ok(ClamEngine::Unix(p.to_path_buf()));
}
which("clamscan")
.map(ClamEngine::Clamscan)
.ok_or_else(|| "neither a clamd socket nor clamscan is installed".to_string())
}
fn instream<S: Read + Write>(mut s: S, bytes: &[u8]) -> ScanOutcome {
let send = |s: &mut S| -> std::io::Result<()> {
s.write_all(b"zINSTREAM\0")?;
for chunk in bytes.chunks(64 * 1024) {
s.write_all(&(chunk.len() as u32).to_be_bytes())?;
s.write_all(chunk)?;
}
s.write_all(&0u32.to_be_bytes())?;
s.flush()
};
if let Err(e) = send(&mut s) {
return ScanOutcome::Error(format!("clamd INSTREAM: {e}"));
}
let mut reply = Vec::new();
if let Err(e) = s.take(64 * 1024).read_to_end(&mut reply) {
if reply.is_empty() {
return ScanOutcome::Error(format!("clamd reply: {e}"));
}
}
parse_clamd_reply(&String::from_utf8_lossy(&reply))
}
}
/// `stream: OK`, `stream: Sig.Name FOUND`, or `… ERROR`.
pub fn parse_clamd_reply(raw: &str) -> ScanOutcome {
let line = raw.trim_matches(|c: char| c == '\0' || c.is_whitespace());
let body = line.split_once(": ").map(|(_, b)| b).unwrap_or(line);
if body == "OK" {
ScanOutcome::Clean
} else if let Some(sig) = body.strip_suffix(" FOUND") {
ScanOutcome::Detected(vec![sig.to_string()])
} else {
ScanOutcome::Error(format!("clamd answered {line:?}"))
}
}
impl MalwareScanner for ClamAv {
fn name(&self) -> &str {
"clamav"
}
fn required(&self) -> bool {
self.cfg.required
}
fn scan(&self, bytes: &[u8], _name: &str) -> ScanOutcome {
let t = Some(Duration::from_secs(self.cfg.timeout_secs.max(1)));
match self.engine() {
Err(why) => ScanOutcome::NotConfigured(why),
#[cfg(unix)]
Ok(ClamEngine::Unix(p)) => match std::os::unix::net::UnixStream::connect(&p) {
Ok(s) => {
let _ = s.set_read_timeout(t);
let _ = s.set_write_timeout(t);
Self::instream(s, bytes)
}
Err(e) => ScanOutcome::Error(format!("clamd {}: {e}", p.display())),
},
Ok(ClamEngine::Tcp(addr)) => match std::net::TcpStream::connect(&addr) {
Ok(s) => {
let _ = s.set_read_timeout(t);
let _ = s.set_write_timeout(t);
Self::instream(s, bytes)
}
Err(e) => ScanOutcome::Error(format!("clamd {addr}: {e}")),
},
Ok(ClamEngine::Clamscan(bin)) => {
// A32-14, a process by design: the malware scan runs ClamAV's clamscan when no clamd socket is fed; the scanner is the feature.
let mut cmd = Command::new(&bin);
cmd.args(["--no-summary", "--stdout", "-"]);
match run(cmd, Some(bytes)) {
Ok((Some(0), _, _)) => ScanOutcome::Clean,
Ok((Some(1), out, _)) => ScanOutcome::Detected(
out.lines()
.filter_map(|l| l.strip_suffix(" FOUND"))
.map(|l| l.split_once(": ").map(|(_, s)| s).unwrap_or(l).to_string())
.collect(),
),
Ok((code, _, err)) => {
ScanOutcome::Error(format!("clamscan exit {code:?}: {}", err.trim()))
}
Err(e) => ScanOutcome::Error(format!("clamscan: {e}")),
}
}
}
}
}
// ── YARA ────────────────────────────────────────────────────────────────────
/// The `yara:` block.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct YaraConfig {
/// A rule file, or a directory of `.yar` / `.yara` files.
pub rules: Option<String>,
/// The `yara` binary for rules beyond the built-in subset. Unset: PATH.
pub binary: Option<String>,
pub required: bool,
}
#[derive(Debug, Clone)]
enum YaraEngine {
/// The rules fit the built-in matcher.
Builtin(crate::yara_lite::Rules),
/// The rules need the real engine: these rule files, this binary.
Binary {
bin: PathBuf,
files: Vec<PathBuf>,
},
NotConfigured(String),
}
/// The YARA adapter.
#[derive(Debug, Clone)]
pub struct Yara {
engine: YaraEngine,
required: bool,
}
impl Yara {
pub fn from_config(cfg: &YaraConfig) -> Self {
let engine = match &cfg.rules {
None => YaraEngine::NotConfigured("no rules are configured".into()),
Some(path) => match Self::rule_files(Path::new(path)) {
Err(why) => YaraEngine::NotConfigured(why),
Ok(files) => Self::compile(&files, cfg.binary.as_deref()),
},
};
Self {
engine,
required: cfg.required,
}
}
/// Rules from source, for tests and embedders. No binary fallback.
pub fn from_source(src: &str, required: bool) -> Self {
let engine = match crate::yara_lite::Rules::parse(src) {
Ok(r) => YaraEngine::Builtin(r),
Err(e) => YaraEngine::NotConfigured(format!("rules need the yara engine: {e}")),
};
Self { engine, required }
}
/// Is this adapter able to scan, and with what?
pub fn engine_name(&self) -> Result<&'static str, String> {
match &self.engine {
YaraEngine::Builtin(_) => Ok("built-in"),
YaraEngine::Binary { .. } => Ok("yara binary"),
YaraEngine::NotConfigured(why) => Err(why.clone()),
}
}
fn rule_files(p: &Path) -> Result<Vec<PathBuf>, String> {
if p.is_file() {
return Ok(vec![p.to_path_buf()]);
}
let rd = std::fs::read_dir(p).map_err(|e| format!("rules {}: {e}", p.display()))?;
let mut files: Vec<PathBuf> = rd
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|f| f.extension().is_some_and(|x| x == "yar" || x == "yara"))
.collect();
files.sort();
if files.is_empty() {
return Err(format!("no .yar or .yara files in {}", p.display()));
}
Ok(files)
}
fn compile(files: &[PathBuf], binary: Option<&str>) -> YaraEngine {
let mut src = String::new();
for f in files {
match std::fs::read_to_string(f) {
Ok(s) => {
src.push_str(&s);
src.push('\n');
}
Err(e) => return YaraEngine::NotConfigured(format!("rules {}: {e}", f.display())),
}
}
match crate::yara_lite::Rules::parse(&src) {
Ok(r) => YaraEngine::Builtin(r),
Err(why) => {
let bin = match binary {
Some(b) => Some(PathBuf::from(b)).filter(|p| is_executable(p)),
None => which("yara"),
};
match bin {
Some(bin) => YaraEngine::Binary { bin, files: files.to_vec() },
None => YaraEngine::NotConfigured(format!(
"the rules need the yara engine ({why}), and the yara binary is not installed"
)),
}
}
}
}
}
impl MalwareScanner for Yara {
fn name(&self) -> &str {
"yara"
}
fn required(&self) -> bool {
self.required
}
fn reads_members(&self) -> bool {
true
}
fn scan(&self, bytes: &[u8], _name: &str) -> ScanOutcome {
match &self.engine {
YaraEngine::NotConfigured(why) => ScanOutcome::NotConfigured(why.clone()),
YaraEngine::Builtin(rules) => {
let hits = rules.scan(bytes);
if hits.is_empty() {
ScanOutcome::Clean
} else {
ScanOutcome::Detected(hits)
}
}
YaraEngine::Binary { bin, files } => {
let mut tmp = match tempfile::NamedTempFile::new() {
Ok(t) => t,
Err(e) => return ScanOutcome::Error(format!("temp file: {e}")),
};
if let Err(e) = tmp.write_all(bytes).and_then(|_| tmp.flush()) {
return ScanOutcome::Error(format!("temp file: {e}"));
}
// A32-14, a process by design: the YARA scan runs the yara program on the bytes; the scanner is the feature.
let mut cmd = Command::new(bin);
cmd.arg("-w").args(files).arg(tmp.path());
match run(cmd, None) {
Ok((Some(0), out, _)) => {
let hits: Vec<String> = out
.lines()
.filter_map(|l| l.split_whitespace().next())
.map(str::to_string)
.collect();
if hits.is_empty() {
ScanOutcome::Clean
} else {
ScanOutcome::Detected(hits)
}
}
Ok((code, _, err)) => {
ScanOutcome::Error(format!("yara exit {code:?}: {}", err.trim()))
}
Err(e) => ScanOutcome::Error(format!("yara: {e}")),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamd_replies_parse() {
assert_eq!(parse_clamd_reply("stream: OK\0"), ScanOutcome::Clean);
assert_eq!(
parse_clamd_reply("stream: Eicar-Test-Signature FOUND\0"),
ScanOutcome::Detected(vec!["Eicar-Test-Signature".into()])
);
assert!(matches!(
parse_clamd_reply("INSTREAM size limit exceeded. ERROR\0"),
ScanOutcome::Error(_)
));
}
#[test]
fn a_clamd_reply_without_the_stream_prefix_still_parses() {
assert_eq!(parse_clamd_reply("OK"), ScanOutcome::Clean);
assert_eq!(
parse_clamd_reply(" Win.Test FOUND \n"),
ScanOutcome::Detected(vec!["Win.Test".into()])
);
assert_eq!(
parse_clamd_reply(""),
ScanOutcome::Error("clamd answered \"\"".into())
);
}
/// A stand-in clamd on TCP: reads one INSTREAM, records the chunk sizes, answers `reply`.
fn tcp_clamd(reply: &'static [u8]) -> (String, std::thread::JoinHandle<Vec<usize>>) {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut cmd = [0u8; 10];
s.read_exact(&mut cmd).unwrap();
assert_eq!(&cmd, b"zINSTREAM\0");
let mut sizes = Vec::new();
loop {
let mut len = [0u8; 4];
s.read_exact(&mut len).unwrap();
let n = u32::from_be_bytes(len) as usize;
if n == 0 {
break;
}
let mut chunk = vec![0u8; n];
s.read_exact(&mut chunk).unwrap();
sizes.push(n);
}
s.write_all(reply).unwrap();
sizes
});
(addr, h)
}
#[test]
fn clamd_over_tcp_gets_the_bytes_in_64_kib_chunks() {
let (addr, h) = tcp_clamd(b"stream: OK\0");
let clam = ClamAv::from_config(&ClamAvConfig {
socket: Some(addr.clone()),
..Default::default()
});
assert_eq!(clam.engine(), Ok(ClamEngine::Tcp(addr)));
assert_eq!(
clam.scan(&vec![7u8; 150 * 1024], "big.bin"),
ScanOutcome::Clean
);
assert_eq!(h.join().unwrap(), vec![65536, 65536, 150 * 1024 - 131072]);
let (addr, h) = tcp_clamd(b"stream: Win.Test.Sig FOUND\0");
let clam = ClamAv::from_config(&ClamAvConfig {
socket: Some(addr),
..Default::default()
});
assert_eq!(
clam.scan(b"x", "x"),
ScanOutcome::Detected(vec!["Win.Test.Sig".into()])
);
h.join().unwrap();
}
#[test]
fn a_clamd_that_answers_nothing_or_is_not_there_is_an_error_not_clean() {
let (addr, h) = tcp_clamd(b"");
let clam = ClamAv::from_config(&ClamAvConfig {
socket: Some(addr),
..Default::default()
});
assert!(
matches!(clam.scan(b"x", "x"), ScanOutcome::Error(e) if e.contains("clamd answered"))
);
h.join().unwrap();
let dead = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().to_string()
};
let clam = ClamAv::from_config(&ClamAvConfig {
socket: Some(dead.clone()),
..Default::default()
});
assert!(
matches!(clam.scan(b"x", "x"), ScanOutcome::Error(e) if e.starts_with(&format!("clamd {dead}")))
);
}
#[cfg(unix)]
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let p = dir.join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
}
/// A freshly written script can be "busy" for a moment while another test forks.
#[cfg(unix)]
fn settled(f: impl Fn() -> ScanOutcome) -> ScanOutcome {
for _ in 0..20 {
match f() {
ScanOutcome::Error(e) if e.contains("busy") => {
std::thread::sleep(Duration::from_millis(50))
}
o => return o,
}
}
f()
}
#[cfg(unix)]
#[test]
fn clamscan_exit_codes_map_to_clean_detected_and_error() {
let d = tempfile::tempdir().unwrap();
let clam = |bin: &Path| {
ClamAv::from_config(&ClamAvConfig {
clamscan: Some(bin.display().to_string()),
..Default::default()
})
};
let clean = script(d.path(), "clean", "cat >/dev/null; exit 0");
let found = script(
d.path(),
"found",
"cat >/dev/null; echo \"stdin: Win.Test.Sig FOUND\"; echo \"stdin: Other FOUND\"; exit 1",
);
let broken = script(d.path(), "broken", "cat >/dev/null; echo boom >&2; exit 2");
assert_eq!(
clam(&clean).engine(),
Ok(ClamEngine::Clamscan(clean.clone()))
);
assert_eq!(
settled(|| clam(&clean).scan(b"abc", "a")),
ScanOutcome::Clean
);
assert_eq!(
settled(|| clam(&found).scan(b"abc", "a")),
ScanOutcome::Detected(vec!["Win.Test.Sig".into(), "Other".into()])
);
assert_eq!(
settled(|| clam(&broken).scan(b"abc", "a")),
ScanOutcome::Error("clamscan exit Some(2): boom".into())
);
// A clamscan that is not executable is not an engine.
let plain = d.path().join("plain");
std::fs::write(&plain, "not a program").unwrap();
assert!(
matches!(clam(&plain).scan(b"x", "x"), ScanOutcome::NotConfigured(w) if w.contains("is not installed"))
);
}
#[cfg(unix)]
#[test]
fn rules_beyond_the_built_in_subset_run_on_the_yara_binary() {
let d = tempfile::tempdir().unwrap();
let rules = d.path().join("rules");
std::fs::create_dir(&rules).unwrap();
std::fs::write(
rules.join("r.yar"),
"rule R { strings: $a = /x+/ condition: $a }",
)
.unwrap();
std::fs::write(rules.join("notes.txt"), "not a rule").unwrap();
// The fake engine checks it is called as `yara -w <rule files> <target>`.
let hit = script(
d.path(),
"yara-hit",
"[ \"$1\" = -w ] || exit 9; case \"$2\" in *r.yar) ;; *) exit 8;; esac; echo \"R $3\"; exit 0",
);
let miss = script(d.path(), "yara-miss", "exit 0");
let broken = script(d.path(), "yara-broken", "echo bad rules >&2; exit 1");
let y = |bin: &Path| {
Yara::from_config(&YaraConfig {
rules: Some(rules.display().to_string()),
binary: Some(bin.display().to_string()),
required: true,
})
};
assert_eq!(y(&hit).engine_name(), Ok("yara binary"));
assert!(y(&hit).reads_members());
assert_eq!(
settled(|| y(&hit).scan(b"xxx", "a")),
ScanOutcome::Detected(vec!["R".into()])
);
assert_eq!(settled(|| y(&miss).scan(b"xxx", "a")), ScanOutcome::Clean);
assert_eq!(
settled(|| y(&broken).scan(b"xxx", "a")),
ScanOutcome::Error("yara exit Some(1): bad rules".into())
);
// The same rules with a binary that is not executable: not configured, and saying why.
let plain = d.path().join("plain");
std::fs::write(&plain, "x").unwrap();
let e = y(&plain).engine_name().unwrap_err();
assert!(e.contains("yara binary is not installed"), "{e}");
}
#[test]
fn yara_rule_paths_that_hold_no_rules_are_not_configured() {
let d = tempfile::tempdir().unwrap();
let cfg = |p: &Path| YaraConfig {
rules: Some(p.display().to_string()),
..Default::default()
};
let e = Yara::from_config(&cfg(d.path())).engine_name().unwrap_err();
assert!(e.contains("no .yar or .yara files"), "{e}");
let e = Yara::from_config(&cfg(&d.path().join("absent")))
.engine_name()
.unwrap_err();
assert!(e.starts_with("rules "), "{e}");
// One rule file named directly loads on the built-in matcher.
let f = d.path().join("one.yara");
std::fs::write(&f, "rule Hello { strings: $a = \"hello\" condition: $a }").unwrap();
let y = Yara::from_config(&cfg(&f));
assert_eq!(y.engine_name(), Ok("built-in"));
assert_eq!(
y.scan(b"say hello", "x"),
ScanOutcome::Detected(vec!["Hello".into()])
);
// A rule file that is not UTF-8 is refused as rules, not half read.
std::fs::remove_file(&f).unwrap();
std::fs::write(d.path().join("bad.yar"), [0xff, 0xfe, 0x00]).unwrap();
let e = Yara::from_config(&cfg(d.path())).engine_name().unwrap_err();
assert!(e.starts_with("rules "), "{e}");
}
/// A scanner that answers what it is told.
struct Fixed {
outcome: ScanOutcome,
required: bool,
members: bool,
}
impl MalwareScanner for Fixed {
fn name(&self) -> &str {
"fixed"
}
fn required(&self) -> bool {
self.required
}
fn scan(&self, _: &[u8], _: &str) -> ScanOutcome {
self.outcome.clone()
}
fn reads_members(&self) -> bool {
self.members
}
}
fn fixed(
outcome: ScanOutcome,
required: bool,
members: bool,
) -> std::sync::Arc<dyn MalwareScanner> {
std::sync::Arc::new(Fixed {
outcome,
required,
members,
})
}
#[test]
fn a_failing_scanner_warns_when_optional_and_blocks_when_required() {
use crate::{Format, Kind};
let run = |s: std::sync::Arc<dyn MalwareScanner>, name: &str| {
let mut f = Findings::new(Format::Wheel, Kind::Zip);
scan_artifact(&[s], b"bytes", name, &mut f);
f
};
let f = run(
fixed(ScanOutcome::Error("timeout".into()), false, false),
"a.whl",
);
assert_eq!(
(f.findings[0].kind, f.findings[0].severity),
(FindingKind::ScannerError, Severity::Warn)
);
assert_eq!(f.findings[0].detail, "fixed: timeout");
let f = run(
fixed(ScanOutcome::Error("timeout".into()), true, false),
"a.whl",
);
assert_eq!(f.findings[0].severity, Severity::Block);
let f = run(
fixed(ScanOutcome::NotConfigured("absent".into()), true, false),
"",
);
assert_eq!(
(f.findings[0].kind, f.findings[0].severity),
(FindingKind::ScannerNotConfigured, Severity::Block)
);
assert_eq!(
f.findings[0].path, "<wheel>",
"an unnamed artifact is labelled by its format"
);
assert!(f.findings[0].detail.starts_with("required scanner fixed"));
let f = run(
fixed(
ScanOutcome::Detected(vec!["A".into(), "B".into()]),
false,
false,
),
"a.whl",
);
assert_eq!(
(f.findings[0].kind, f.findings[0].severity),
(FindingKind::Malware, Severity::Block)
);
assert_eq!(f.findings[0].detail, "fixed: A, B");
let f = run(fixed(ScanOutcome::Clean, true, false), "a.whl");
assert!(f.findings.is_empty());
assert_eq!(f.scanners, vec!["fixed".to_string()]);
}
#[test]
fn a_member_scan_reports_hits_and_errors_at_the_member_and_nothing_else() {
use crate::{Format, Kind};
let run = |s: std::sync::Arc<dyn MalwareScanner>| {
let mut f = Findings::new(Format::Wheel, Kind::Zip);
scan_member(&[s], b"bytes", "m.py", "a.whl!m.py", &mut f);
f
};
let f = run(fixed(ScanOutcome::Detected(vec!["R".into()]), false, true));
assert_eq!(f.findings[0].path, "a.whl!m.py");
assert_eq!(f.findings[0].severity, Severity::Block);
let f = run(fixed(ScanOutcome::Error("e".into()), false, true));
assert_eq!(f.findings[0].severity, Severity::Warn);
let f = run(fixed(ScanOutcome::Error("e".into()), true, true));
assert_eq!(f.findings[0].severity, Severity::Block);
// Not configured is said once, by the artifact scan; a clean member says nothing.
assert!(
run(fixed(ScanOutcome::NotConfigured("x".into()), true, true))
.findings
.is_empty()
);
assert!(run(fixed(ScanOutcome::Clean, true, true))
.findings
.is_empty());
// A scanner that does not read members is not asked.
assert!(
run(fixed(ScanOutcome::Detected(vec!["R".into()]), true, false))
.findings
.is_empty()
);
}
}