diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b0968..2f399bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,37 @@ Le format suit l'esprit de [Keep a Changelog](https://keepachangelog.com/fr/1.1. ### Interne +- `examples/scan_pcaps.rs` lit desormais les captures avec `pcap-file` au lieu + de libpcap, et resout le LINKTYPE **par paquet** au lieu d'appeler + `get_datalink()` une seule fois par fichier. + + Un pcapng peut decrire plusieurs interfaces, chacune avec son LINKTYPE, et + chaque paquet designe la sienne par `interface_id`. L'ancienne boucle + appliquait le LINKTYPE de la premiere interface a tout le fichier et sortait + sur le premier `Err`, si bien que la capture s'arretait au premier changement + d'encapsulation — silencieusement, en affichant zero trame. + + Trois fichiers du corpus etaient concernes, pas un seul : + `The-Ultimate-PCAP.pcapng` (0 -> **51 328** trames), `capwap-only.pcapng` + (0 -> 2) et `capwap-association-valid.pcapng` (0 -> 2). Les trois comptes + correspondent exactement a ceux de tshark. Aucun autre fichier du corpus ne + change de compte (issue #74). + + Une lecture interrompue par une erreur est desormais distinguee d'une fin de + fichier et signalee, fichier par fichier puis en recapitulatif : un total de + corpus bati sur des lectures amputees ne se presente plus comme complet. + + Le chemin pcap classique utilise `next_raw_packet` : `next_packet` rejette + `orig_len > snap_len` comme invalide, ce qui est plus strict que libpcap et + que la realite d'une trame tronquee a la capture — cette validation faisait + tomber une capture de 3 584 trames a 4. + +- Les erreurs de couche liaison rapportees par `scan_pcaps` portent leur + LINKTYPE. Sur `The-Ultimate-PCAP.pcapng`, cela montre que les 10 667 echecs + proviennent tous du LINKTYPE 274 — **IEEE 802.3br mPackets**, le seul des + quatre encapsulations du fichier qui n'ait pas de decodeur. Les trois autres + (Ethernet 39 557, Linux SLL v1 1 067, Raw IP 37) sont decodees. + - `Cargo.toml` passe d'une liste `exclude` a une liste `include`, fail-closed par construction. Chaque release crates.io embarquait jusqu'ici cinq `.DS_Store`, `.codex`, `analyse.md`, `sprint_0{1,2}.md`, `oui.csv`, diff --git a/examples/scan_pcaps.rs b/examples/scan_pcaps.rs index c974013..58eb520 100644 --- a/examples/scan_pcaps.rs +++ b/examples/scan_pcaps.rs @@ -12,12 +12,32 @@ //! Avec `--focus`, chaque trame détectée comme PROTO est détaillée //! (n° de trame, ports, premiers octets du payload) pour auditer les //! faux positifs. +//! +//! ## Pourquoi ne pas passer par libpcap +//! +//! Un pcapng peut décrire **plusieurs interfaces**, chacune avec son propre +//! LINKTYPE, et chaque paquet référence la sienne par `interface_id`. Lire un +//! tel fichier avec un unique `get_datalink()` en début de fichier revient à +//! appliquer le LINKTYPE de la première interface à tous les paquets ; la +//! lecture s'interrompt au premier changement, et le fichier compte alors zéro +//! trame sans que rien ne le signale (issue #74). +//! +//! `pcap-file` donne accès aux blocs de description d'interface, donc au +//! LINKTYPE réel de chaque paquet. `packet_parser::parse` prend justement le +//! `LinkType` en paramètre : c'est l'itération qui était en cause, pas la +//! crate. use packet_parser::{LinkType, parse}; -use pcap::Capture; +use pcap_file::pcap::PcapReader; +use pcap_file::pcapng::{Block, PcapNgReader}; use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; +/// Premier mot d'un Section Header Block, qui identifie un pcapng. +const PCAPNG_SHB_MAGIC: u32 = 0x0A0D_0D0A; + fn collect_pcaps(path: &Path, out: &mut Vec) { if path.is_dir() { let mut entries: Vec<_> = match std::fs::read_dir(path) { @@ -36,6 +56,146 @@ fn collect_pcaps(path: &Path, out: &mut Vec) { } } +/// Issue de la lecture d'un fichier. +/// +/// `truncated_by` est le point clé : une lecture qui s'arrête sur une erreur +/// n'est pas une lecture complète, et l'ancienne boucle `while let Ok(..)` +/// confondait les deux. Un fichier partiellement lu doit le dire. +#[derive(Default)] +struct ReadOutcome { + frames: usize, + truncated_by: Option, +} + +/// Applique `on_packet` à chaque paquet du fichier, avec le `LinkType` de +/// l'interface dont il provient. +fn for_each_packet( + path: &Path, + on_packet: &mut impl FnMut(LinkType, &[u8]), +) -> std::io::Result { + let mut file = BufReader::new(File::open(path)?); + + let mut magic = [0u8; 4]; + file.read_exact(&mut magic)?; + file.seek(SeekFrom::Start(0))?; + + // Le SHB est le seul bloc dont le type est fixe et connu d'avance ; tout + // le reste (pcap classique, quel que soit son boutisme ou sa resolution + // temporelle) est delegue a PcapReader, qui lit son propre en-tete. + if u32::from_be_bytes(magic) == PCAPNG_SHB_MAGIC { + Ok(read_pcapng(file, on_packet)) + } else { + Ok(read_pcap(file, on_packet)) + } +} + +fn read_pcapng( + reader: BufReader, + on_packet: &mut impl FnMut(LinkType, &[u8]), +) -> ReadOutcome { + let mut outcome = ReadOutcome::default(); + + let mut reader = match PcapNgReader::new(reader) { + Ok(r) => r, + Err(e) => { + outcome.truncated_by = Some(format!("en-tete pcapng illisible ({e})")); + return outcome; + } + }; + + // LINKTYPE par interface, indexe par `interface_id`. Une nouvelle section + // remet la numerotation a zero (RFC pcapng §4.2). + let mut linktypes: Vec = Vec::new(); + + // `next_block` rend `None` en fin de fichier et `Some(Err)` sur erreur de + // lecture : ce sont deux choses differentes. Sur erreur, on s'arrete — le + // parseur sous-jacent ne consomme pas les octets fautifs, donc reessayer + // bouclerait — mais on le signale au lieu de faire passer une lecture + // amputee pour une lecture complete. + while let Some(block) = reader.next_block() { + let block = match block { + Ok(block) => block, + Err(e) => { + outcome.truncated_by = Some(format!( + "bloc illisible apres {} trames ({e})", + outcome.frames + )); + break; + } + }; + + match block { + Block::SectionHeader(_) => linktypes.clear(), + Block::InterfaceDescription(idb) => { + linktypes.push(LinkType::from(u32::from(idb.linktype))); + } + Block::EnhancedPacket(epb) => { + if let Some(&link_type) = linktypes.get(epb.interface_id as usize) { + outcome.frames += 1; + on_packet(link_type, &epb.data); + } + } + // Bloc obsolete (pcapng pre-1.0), conserve par certains outils. + Block::Packet(pb) => { + if let Some(&link_type) = linktypes.get(pb.interface_id as usize) { + outcome.frames += 1; + on_packet(link_type, &pb.data); + } + } + // Le Simple Packet Block ne porte pas d'`interface_id` : il n'est + // valide que si la section decrit une seule interface. + Block::SimplePacket(spb) => { + if let [link_type] = linktypes[..] { + outcome.frames += 1; + on_packet(link_type, &spb.data); + } + } + _ => {} + } + } + + outcome +} + +fn read_pcap(reader: BufReader, on_packet: &mut impl FnMut(LinkType, &[u8])) -> ReadOutcome { + let mut outcome = ReadOutcome::default(); + + let mut reader = match PcapReader::new(reader) { + Ok(r) => r, + Err(e) => { + outcome.truncated_by = Some(format!("en-tete pcap illisible ({e})")); + return outcome; + } + }; + + // Un pcap classique n'a qu'un seul LINKTYPE, dans son en-tete de fichier. + let link_type = LinkType::from(u32::from(reader.header().datalink)); + + // `next_raw_packet` plutot que `next_packet` : ce dernier rejette + // `orig_len > snap_len` comme invalide, ce qui est trop strict. Une trame + // tronquee a la capture a legitimement une longueur de fil superieure au + // snaplen declare, et libpcap l'accepte. Sur le corpus, cette validation + // faisait tomber une capture de 3 584 trames a 4. On ne lit de toute + // facon que `data`. + while let Some(packet) = reader.next_raw_packet() { + match packet { + Ok(packet) => { + outcome.frames += 1; + on_packet(link_type, &packet.data); + } + Err(e) => { + outcome.truncated_by = Some(format!( + "paquet illisible apres {} trames ({e})", + outcome.frames + )); + break; + } + } + } + + outcome +} + fn main() -> Result<(), Box> { let mut args: Vec = std::env::args().skip(1).collect(); let focus = args @@ -57,25 +217,22 @@ fn main() -> Result<(), Box> { } let mut grand_total: BTreeMap = BTreeMap::new(); + let mut incomplets = Vec::new(); for file in &files { - let mut cap = match Capture::from_file(file) { - Ok(c) => c, - Err(e) => { - eprintln!("{}: illisible ({e})", file.display()); - continue; - } - }; - - let link_type = LinkType::from(cap.get_datalink().0 as u32); - let mut tally: BTreeMap = BTreeMap::new(); let mut frame_no = 0usize; - while let Ok(packet) = cap.next_packet() { + + let mut on_packet = |link_type: LinkType, data: &[u8]| { frame_no += 1; - let Ok(flow) = parse(link_type, packet.data) else { - *tally.entry("".into()).or_default() += 1; - continue; + // L'erreur porte son LINKTYPE : sur une capture multi-interfaces, + // un total d'erreurs anonyme ne dit pas s'il manque un decodeur de + // liaison ou si les trames sont reellement corrompues. + let Ok(flow) = parse(link_type, data) else { + *tally + .entry(format!("")) + .or_default() += 1; + return; }; // La détection applicative pertinente est celle du flux le plus @@ -114,9 +271,21 @@ fn main() -> Result<(), Box> { } *tally.entry(label).or_default() += 1; - } + }; - println!("\n== {} ({frame_no} trames)", file.display()); + let outcome = match for_each_packet(file, &mut on_packet) { + Ok(outcome) => outcome, + Err(e) => { + eprintln!("{}: illisible ({e})", file.display()); + continue; + } + }; + + println!("\n== {} ({} trames)", file.display(), outcome.frames); + if let Some(raison) = &outcome.truncated_by { + println!(" ⚠ lecture incomplete : {raison}"); + incomplets.push(file.display().to_string()); + } for (proto, count) in &tally { println!(" {proto:<12} {count}"); *grand_total.entry(proto.clone()).or_default() += count; @@ -128,5 +297,17 @@ fn main() -> Result<(), Box> { println!(" {proto:<12} {count}"); } + // Un total de corpus construit sur des lectures amputees est trompeur : + // c'est exactement ce que faisait l'ancienne boucle, en silence. + if !incomplets.is_empty() { + println!( + "\n⚠ {} fichier(s) lus incompletement, les totaux ci-dessus sont partiels :", + incomplets.len() + ); + for file in &incomplets { + println!(" {file}"); + } + } + Ok(()) }