Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
215 changes: 198 additions & 17 deletions examples/scan_pcaps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>) {
if path.is_dir() {
let mut entries: Vec<_> = match std::fs::read_dir(path) {
Expand All @@ -36,6 +56,146 @@ fn collect_pcaps(path: &Path, out: &mut Vec<PathBuf>) {
}
}

/// 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<String>,
}

/// 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<ReadOutcome> {
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<File>,
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<LinkType> = 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);
}
Comment on lines +132 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report packet blocks with an unknown interface

When an Enhanced Packet Block references an out-of-range interface_id, this branch silently skips it, leaves truncated_by unset, and excludes it from the frame count; the analogous Packet Block branch behaves the same way. Thus a malformed or partially generated pcapng is presented as a complete scan even though packet blocks were discarded, defeating the new incomplete-read reporting; record an error or otherwise mark the outcome incomplete instead.

Useful? React with 👍 / 👎.

}
// 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);
Comment on lines +148 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route simple packets through interface zero

In a valid pcapng section with multiple Interface Description Blocks, a Simple Packet Block is associated with the first interface (interface 0); it does not require the section to contain exactly one interface. Matching only [link_type] silently drops every SPB in such a capture, undercounts frames, shifts --focus frame numbers, and still reports the file as completely read.

Useful? React with 👍 / 👎.

}
}
_ => {}
}
}

outcome
}

fn read_pcap(reader: BufReader<File>, 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<dyn std::error::Error>> {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let focus = args
Expand All @@ -57,25 +217,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}

let mut grand_total: BTreeMap<String, usize> = 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<String, usize> = 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("<erreur L2>".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!("<erreur L2 lt={link_type}>"))
.or_default() += 1;
return;
};

// La détection applicative pertinente est celle du flux le plus
Expand Down Expand Up @@ -114,9 +271,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}

*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;
Expand All @@ -128,5 +297,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}