Surveillance de répertoires en temps réel et analyse antivirus avec Rust et ClamAV
Créez un moniteur Rust qui analyse les fichiers terminés à l’aide d’un démon ClamAV local. Il signale les résultats sains, infectés ou en erreur du scanner sans déplacer ni supprimer de fichiers. Seule une réponse saine explicite est considérée comme saine.
Pourquoi la surveillance en temps réel et Rust ?
Les événements du système de fichiers peuvent arriver avant qu’un processus d’écriture ait terminé.
Un délai ou une taille de fichier inchangée ne prouve pas que l’écriture est terminée. Cet exemple
utilise donc un contrat côté producteur : écrivez un fichier unique se terminant par
.part, fermez-le, puis renommez-le de façon atomique en .ready dans le même répertoire. Ne modifiez jamais un fichier .ready.
Gardez le répertoire privé, réservé à l’application et à son producteur de confiance.
Le moniteur analyse uniquement les fichiers .ready de premier niveau. Il s’agit d’un outil de
notification, et non d’un mécanisme de contrôle d’accès : un résultat décrit les octets analysés et
n’autorise pas une lecture ultérieure d’un chemin modifiable.
Prérequis
Ce tutoriel utilise Linux et Bash, avec Rust et Cargo 1.98.1 ainsi que ClamAV 1.5.4. Prévoyez un
démon clamd local avec une base de signatures à jour et un socket Unix accessible au processus
Rust. Les commandes ci-dessous ciblent Linux ; le client utilise des sockets Unix.
Configuration du démon ClamAV (clamd)
Sous Debian ou Ubuntu, le guide des paquets ClamAV répertorie les paquets du démon et de l’outil de mise à jour des définitions :
sudo apt-get update &&
sudo apt-get install clamav-daemon clamav-freshclam
Dans le fichier clamd.conf de la distribution, conservez ses réglages de base de données et d’utilisateur de service, puis configurez un socket local. Il s’agit d’un fragment de configuration ; assurez-vous qu’aucune directive TCPSocket ne reste activée :
LocalSocket /var/run/clamav/clamd.ctl
LocalSocketMode 660
StreamMaxLength 10M
MaxFileSize 10M
MaxScanSize 20M
AlertExceedsMax yes
Accordez à l’utilisateur de service de l’application l’accès au groupe du socket, puis redémarrez le service. Laissez le service FreshClam de la distribution maintenir la base de données à jour. N’exécutez pas un second outil de mise à jour manuel sur sa base de données verrouillée.
sudo systemctl enable --now clamav-freshclam &&
sudo systemctl restart clamav-daemon
La documentation du protocole ClamAV
décrit le socket et le découpage en trames d’INSTREAM. Clamd n’offre aucune authentification TCP ;
cet exemple utilise uniquement un socket local contrôlé par des permissions. Configurez les limites
d’archives et d’analyse selon votre charge de travail. Lorsque AlertExceedsMax est activé, les
alertes de dépassement de limite du démon ne doivent pas être considérées comme saines.
Configuration du projet
Commencez dans un répertoire accessible en écriture, en dehors de tout projet Cargo existant.
Cargo peut ajouter un nouveau paquet à un espace de travail ancêtre, et
la configuration est héritée des répertoires parents.
Ce bloc Bash refuse ces emplacements avant de créer quoi que ce soit. Il n’entre dans le nouveau
projet qu’après la réussite de cargo new ; un répertoire realtime_virus_scanner existant n’est pas modifié.
(
cd -P . || exit 1
scanner_parent=$PWD
while :; do
if [ -e "$scanner_parent/Cargo.toml" ] ||
[ -e "$scanner_parent/.cargo/config.toml" ] ||
[ -e "$scanner_parent/.cargo/config" ]; then
printf 'Choose a directory outside existing Cargo projects/configuration.\n' >&2
exit 1
fi
[ "$scanner_parent" = / ] && break
scanner_parent=${scanner_parent%/*}
[ -n "$scanner_parent" ] || scanner_parent=/
done
cargo new realtime_virus_scanner --edition 2021 --vcs none
) && cd realtime_virus_scanner
Une fois la configuration réussie, remplacez Cargo.toml dans le nouveau projet par :
[package]
name = "realtime_virus_scanner"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "=4.5.50", features = ["derive"] }
notify = "=8.2.0"
tokio = { version = "=1.48.0", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
Conservez Cargo.lock dans votre application. L’exemple implémente directement le court échange INSTREAM avec Tokio, au lieu de dépendre d’une surcouche cliente ClamAV distincte.
Interface en ligne de commande avec clap
La CLI accepte --directory et --socket. Ajoutez --once pour analyser les fichiers terminés existants,
puis quitter : le code zéro signifie que chaque fichier sélectionné était sain, le code un signifie
qu’une infection ou une erreur du scanner s’est produite. Une sélection vide se termine aussi avec
le code zéro ; cela n’indique rien sur les fichiers .part inachevés. Le mode surveillance répète
les analyses jusqu’à Ctrl+C et signale chaque résultat.
Surveillance de répertoire en temps réel avec notify
Un observateur recommandé réveille le scanner après un événement de création, de modification ou de suppression. Un canal à un seul emplacement regroupe les événements, car chaque réveil analyse de nouveau le répertoire. Un intervalle de réconciliation de cinq secondes détecte aussi les fichiers lorsque des événements sont manqués. Les événements de lecture/accès sont ignorés pour éviter que les analyses ne se déclenchent elles-mêmes.
Analyse antivirus asynchrone avec Tokio
Chaque analyse prend un instantané borné, le transmet en flux par blocs de 64 KiB et lit au plus 4097 octets de réponse. Seule la réponse saine exacte, unique et terminée est acceptée. Les réponses vides, tronquées, surdimensionnées, multiples ou non reconnues sont traitées comme des échecs. Une infection ou une détection de limite du moteur n’est pas un résultat sain. Un délai de dix secondes couvre la lecture du fichier et la communication avec clamd.
Rassembler le tout : src/main.rs
Enregistrez ce programme complet sous src/main.rs :
use clap::Parser;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::{error::Error, path::{Path, PathBuf}, time::Duration};
use tokio::{
fs::{self, File},
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::mpsc,
time::{interval, timeout},
};
type AppResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
const MAX_BYTES: u64 = 10 * 1024 * 1024;
#[derive(Parser)]
struct Args {
#[arg(long)]
directory: PathBuf,
#[arg(long, default_value = "/var/run/clamav/clamd.ctl")]
socket: PathBuf,
#[arg(long)]
once: bool,
}
#[derive(Debug, PartialEq)]
enum Verdict {
Clean,
Infected,
ScannerError,
}
fn parse_reply(reply: &[u8]) -> Verdict {
if reply == b"stream: OK\0" {
return Verdict::Clean;
}
if let Some(name) = reply.strip_prefix(b"stream: ")
.and_then(|value| value.strip_suffix(b" FOUND\0"))
{
if !name.is_empty() && !name.iter().any(|byte| byte.is_ascii_control()) {
return Verdict::Infected;
}
}
Verdict::ScannerError
}
async fn scan(path: &Path, socket: &Path) -> AppResult<Verdict> {
// The private-directory producer contract forbids modifying published .ready files.
if !fs::symlink_metadata(path).await?.file_type().is_file() {
return Err("Input must be a regular, non-symlink file".into());
}
let file = File::open(path).await?;
if !file.metadata().await?.is_file() {
return Err("Input must be a regular file".into());
}
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).await?;
if bytes.len() as u64 > MAX_BYTES {
return Err("Input exceeds 10 MiB".into());
}
let mut stream = UnixStream::connect(socket).await?;
stream.write_all(b"zINSTREAM\0").await?;
for chunk in bytes.chunks(64 * 1024) {
stream.write_all(&(chunk.len() as u32).to_be_bytes()).await?;
stream.write_all(chunk).await?;
}
stream.write_all(&0u32.to_be_bytes()).await?;
let mut reply = Vec::new();
stream.take(4097).read_to_end(&mut reply).await?;
Ok(if reply.len() > 4096 {
Verdict::ScannerError
} else {
parse_reply(&reply)
})
}
async fn scan_directory(args: &Args) -> AppResult<bool> {
let mut entries = fs::read_dir(&args.directory).await?;
let mut all_clean = true;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("ready") {
continue;
}
let verdict = match timeout(Duration::from_secs(10), scan(&path, &args.socket)).await {
Ok(Ok(verdict)) => verdict,
_ => Verdict::ScannerError,
};
all_clean &= verdict == Verdict::Clean;
// Debug path formatting escapes newlines and control characters in filenames.
println!("{verdict:?} {:?}", path.file_name());
}
Ok(all_clean)
}
async fn monitor(args: &Args) -> AppResult<bool> {
if args.once {
return scan_directory(args).await;
}
let (tx, mut rx) = mpsc::channel(1);
let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
let changed = match event {
Ok(event) => matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
),
Err(_) => {
eprintln!("Watcher error; periodic reconciliation remains active.");
true
}
};
if changed {
// A full queue already guarantees a complete directory reconciliation.
let _ = tx.try_send(());
}
})?;
watcher.watch(&args.directory, RecursiveMode::NonRecursive)?;
let mut ticks = interval(Duration::from_secs(5));
loop {
tokio::select! {
_ = ticks.tick() => {}
event = rx.recv() => {
if event.is_none() {
return Err("Watcher channel closed".into());
}
}
}
scan_directory(args).await?;
}
}
async fn run() -> AppResult<bool> {
let args = Args::parse();
tokio::select! {
result = monitor(&args) => result,
result = tokio::signal::ctrl_c() => {
result?;
Err("Scanning interrupted".into())
}
}
}
#[tokio::main]
async fn main() {
match run().await {
Ok(true) => {}
Ok(false) => std::process::exit(1),
Err(_) => {
eprintln!("Scanner stopped; check the directory, socket, and daemon configuration.");
std::process::exit(1);
}
}
}
Depuis le répertoire du projet, compilez et démarrez le moniteur avec une nouvelle boîte de réception privée. Chaque étape doit réussir avant l’exécution de la suivante, afin qu’une compilation échouée ne puisse pas lancer un ancien exécutable :
mkdir -m 700 inbox &&
cargo build --jobs 2 --target-dir target &&
./target/debug/realtime_virus_scanner --directory inbox --socket /var/run/clamav/clamd.ctl
Modifiez l’argument du socket si votre démon utilise un chemin différent. Laissez ce terminal ouvert. Dans un autre terminal, depuis le répertoire du projet, publiez un fichier terminé en utilisant une paire de noms inutilisée :
(
set -o noclobber
test ! -e inbox/example.ready &&
test ! -L inbox/example.ready &&
test ! -e inbox/example.part &&
test ! -L inbox/example.part &&
printf 'A completed example file.\n' > inbox/example.part &&
mv -n -- inbox/example.part inbox/example.ready &&
test ! -e inbox/example.part
)
Une analyse saine affiche Clean Some("example.ready"), éventuellement plusieurs fois, car les réconciliations
analysent de nouveau les fichiers terminés. Le bloc producteur renvoie une valeur non nulle si l’un
des deux noms existe déjà, sans remplacer son contenu. Utilisez de nouveaux noms pour les fichiers
suivants et n’exécutez pas de producteurs simultanément avec les mêmes noms. La commande printf
ferme le fichier avant que mv ne le publie.
Appuyez sur Ctrl+C dans le terminal du moniteur pour l’arrêter. Pour analyser une seule fois la même boîte de réception, exécutez :
./target/debug/realtime_virus_scanner --directory inbox --socket /var/run/clamav/clamd.ctl --once
Gestion des erreurs et notifications
ScannerError signifie que l’analyse a échoué, notamment en cas de socket indisponible, d’entrée de
plus de 10 MiB, de lien symbolique ou d’expiration du délai. Infected couvre aussi les alertes de
dépassement de limite configurées dans ClamAV. Aucun de ces résultats ne devrait libérer un fichier
vers les consommateurs en aval. L’exemple laisse délibérément chaque fichier en place afin qu’un
échec d’analyse ne puisse pas supprimer un fichier téléversé.
Ctrl+C abandonne la future d’analyse active, ferme son socket, libère l’observateur et se termine
avec un code non nul. En mode surveillance, une analyse échouée est relancée lors d’une
réconciliation ultérieure. Utilisez --once lorsqu’un appelant a besoin d’un code de sortie
d’échec pour un lot non sain.
Considérations de performances
Il y a une seule analyse active à la fois, un seul réveil en attente, un instantané borné par fichier et une réponse bornée. L’énumération du répertoire n’accumule pas de file d’attente de chemins. Réanalyser tous les fichiers terminés sacrifie le débit au profit d’un exemple réduit et capable de se rétablir ; un service plus important devrait suivre le travail de manière durable et associer chaque verdict à l’objet immuable exact qu’il a analysé.
Ce moniteur n’est pas récursif, et l’intervalle de cinq secondes est un calendrier de réconciliation plutôt qu’un délai de détection garanti. Les lots volumineux et les analyses lentes prennent plus de temps. Maintenez les signatures à jour et testez les limites d’archives de votre démon dans le cadre de l’exploitation du service.
Conclusion
Seuls des producteurs de confiance devraient publier des fichiers .ready, et les consommateurs ne
doivent pas considérer ce moniteur comme une autorisation de les servir. Clean signifie que le
moteur ClamAV configuré n’a rien détecté dans les octets qu’il a analysés, et non que le fichier
est inoffensif. Maintenez les signatures à jour et choisissez des limites de démon adaptées aux
formats que vous acceptez.
Transloadit utilise également ClamAV pour filtrer les fichiers grâce à son Robot 🤖 /file/virusscan (English).
