diff mbox series

[v2,for,2025.02.x,07/15] package/clamav: add patch for CVE-2026-20243

Message ID 20260902131640.379588-8-titouan.christophe@mind.be
State New
Headers show
Series package/clamav: update to upstream LTS | expand

Commit Message

Titouan Christophe Sept. 2, 2026, 1:16 p.m. UTC
Signed-off-by: Titouan Christophe <titouan.christophe@mind.be>
---
 ...rser-robustness-and-scan-coverage-63.patch | 1503 +++++++++++++++++
 package/clamav/clamav.mk                      |    3 +
 2 files changed, 1506 insertions(+)
 create mode 100644 package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch
diff mbox series

Patch

diff --git a/package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch b/package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch
new file mode 100644
index 0000000000..3b02ceb880
--- /dev/null
+++ b/package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch
@@ -0,0 +1,1503 @@ 
+From: "Val S." <valsnyde@cisco.com>
+Date: Wed, 17 Jun 2026 10:07:43 -0400
+Subject: Fix ALZ parser robustness and scan coverage (#63)
+
+Malformed ALZ entries could panic or abort archive processing before
+payloads and metadata were scanned. Header size claims and decompression
+behavior could bypass scan limits or skip valid members, and the deflate
+path could inflate oversized output before applying limits.
+
+Harden ALZ size arithmetic, extract stored, bzip2, and deflate members
+under scan budgets, and keep scanning after metadata, extraction, and
+later parse errors. Report accurate metadata including file positions
+and encryption state. Use bounded flate2 reads for deflate output, drop
+the old inflate dependency, and add Rust and clamscan regressions.
+
+Keep ALZ per-member and archive-total limit accounting separate so
+archive totals do not get reported as MaxFileSize for archives with
+multiple smaller members. Preserve MaxScanSize exceeds-max reporting for
+truncated archive totals and cover the behavior with Rust and clamscan
+tests.
+
+Credit: Yazdan Soltani
+
+CLAM-2988
+
+---
+Upstream: https://github.com/Cisco-Talos/clamav/commit/610c1d1eeb67ced3645c205d62bcd9eb7e2000ed
+CVE: CVE-2026-20243
+[Titouan: fix merge conflict with clamav-1.4.3]
+Signed-off-by: Titouan Christophe <titouan.christophe@mind.be>
+---
+ Cargo.lock                      |  16 -
+ libclamav/libclamav.map         |   1 +
+ libclamav_rust/Cargo.toml       |   1 -
+ libclamav_rust/build.rs         |   1 +
+ libclamav_rust/src/alz.rs       | 622 +++++++++++++++++++++++++++-----
+ libclamav_rust/src/scanners.rs  | 123 ++++++-
+ libclamav_rust/src/sys.rs       |   6 +
+ libclamav_rust/src/util.rs      |  31 +-
+ unit_tests/clamscan/alz_test.py | 321 ++++++++++++++++
+ 9 files changed, 1006 insertions(+), 116 deletions(-)
+
+diff --git a/Cargo.lock b/Cargo.lock
+index e96f29ffa..8ec8d3400 100644
+--- a/Cargo.lock
++++ b/Cargo.lock
+@@ -8,12 +8,6 @@ version = "1.0.2"
+ source = "registry+https://github.com/rust-lang/crates.io-index"
+ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+ 
+-[[package]]
+-name = "adler32"
+-version = "1.2.0"
+-source = "registry+https://github.com/rust-lang/crates.io-index"
+-checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
+-
+ [[package]]
+ name = "aho-corasick"
+ version = "1.1.3"
+@@ -199,7 +193,6 @@ dependencies = [
+  "hex",
+  "hex-literal",
+  "image",
+- "inflate",
+  "libc",
+  "log",
+  "num-traits",
+@@ -523,12 +516,14 @@ dependencies = [
+ ]
+ 
+ [[package]]
+-name = "inflate"
+-version = "0.4.5"
++name = "interpolate_name"
++version = "0.2.4"
+ source = "registry+https://github.com/rust-lang/crates.io-index"
+-checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff"
++checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
+ dependencies = [
+- "adler32",
++ "proc-macro2",
++ "quote",
++ "syn 2.0.113",
+ ]
+ 
+ [[package]]
+diff --git a/libclamav/libclamav.map b/libclamav/libclamav.map
+index a34a05dbb..f8cf8a299 100644
+--- a/libclamav/libclamav.map
++++ b/libclamav/libclamav.map
+@@ -292,6 +292,7 @@ CLAMAV_PRIVATE {
+     cli_getdsig;
+     cli_infomsg_simple;
+     cli_set_debug_flag;
++    cli_append_potentially_unwanted_if_heur_exceedsmax;
+     fmap_dump_to_file;
+     init_allow_list;
+     init_domain_list;
+diff --git a/libclamav_rust/Cargo.toml b/libclamav_rust/Cargo.toml
+index efab908656..b75185b6c7 100644
+--- a/libclamav_rust/Cargo.toml
++++ b/libclamav_rust/Cargo.toml
+@@ -22,7 +22,6 @@ unicode-segmentation = "1.10"
+ bindgen = "0.65"
+ onenote_parser = { git = "https://github.com/Cisco-Talos/onenote.rs.git", branch = "CLAM-2329-new-from-slice" }
+ hex-literal = "0.4"
+-inflate = "0.4"
+ bzip2-rs = "0.1"
+ byteorder = "1.5"
+ delharc = "0.6"
+diff --git a/libclamav_rust/build.rs b/libclamav_rust/build.rs
+index ede1743f41..a35bc6211a 100644
+--- a/libclamav_rust/build.rs
++++ b/libclamav_rust/build.rs
+@@ -48,6 +48,7 @@ const BINDGEN_FUNCTIONS: &[&str] = &[
+     "cli_infomsg_simple",
+     "cli_errmsg",
+     "cli_append_virus",
++    "cli_append_potentially_unwanted_if_heur_exceedsmax",
+     "lsig_increment_subsig_match",
+     "cli_versig2",
+     "cli_getdsig",
+diff --git a/libclamav_rust/src/alz.rs b/libclamav_rust/src/alz.rs
+index fef676a0db..0ee30a0a04 100644
+--- a/libclamav_rust/src/alz.rs
++++ b/libclamav_rust/src/alz.rs
+@@ -30,11 +30,12 @@
+ )]
+ */
+ 
++use std::convert::TryFrom;
+ use std::io::{Cursor, Read};
+ 
+ use byteorder::{LittleEndian, ReadBytesExt};
+ use bzip2_rs::DecoderReader;
+-use inflate::InflateStream;
++use flate2::read::DeflateDecoder;
+ use log::debug;
+ 
+ /// File header
+@@ -61,6 +62,12 @@ pub enum Error {
+     #[error("Failed to extract file")]
+     Extract,
+ 
++    #[error("Failed to allocate extracted file")]
++    Alloc,
++
++    #[error("Extracted file exceeds scan limits")]
++    ScanLimitExceeded(u64),
++
+     #[error("Failed to read field: {0}")]
+     Read(&'static str),
+ }
+@@ -273,16 +280,20 @@ impl AlzLocalFileHeader {
+             }
+         }
+ 
+-        #[allow(clippy::cast_possible_truncation)]
+-        let idx0: usize = cursor.position() as usize;
+-        let idx1: usize = idx0 + self.head.file_name_length as usize;
++        let idx0: usize = usize::try_from(cursor.position())
++            .map_err(|_| Error::Parse("Invalid file name offset"))?;
++        let idx1: usize = idx0
++            .checked_add(usize::from(self.head.file_name_length))
++            .ok_or(Error::Parse("Invalid file name length"))?;
+ 
+         if idx1 > cursor.get_ref().len() {
+             return Err(Error::Parse("Invalid file name length"));
+         }
+ 
+         let filename = &cursor.get_ref().as_slice()[idx0..idx1];
+-        cursor.set_position(idx1 as u64);
++        cursor.set_position(
++            u64::try_from(idx1).map_err(|_| Error::Parse("Invalid file name length"))?,
++        );
+ 
+         self.file_name = String::from_utf8_lossy(filename).into_owned();
+ 
+@@ -293,12 +304,20 @@ impl AlzLocalFileHeader {
+         }
+ 
+         self.start_of_compressed_data = cursor.position();
+-        cursor.set_position(self.start_of_compressed_data + self.compressed_size);
+-
+-        if self.start_of_compressed_data + self.compressed_size > cursor.get_ref().len() as u64 {
++        let end_of_compressed_data = self
++            .start_of_compressed_data
++            .checked_add(self.compressed_size)
++            .ok_or(Error::Parse("Invalid compressed data length"))?;
++
++        if end_of_compressed_data
++            > u64::try_from(cursor.get_ref().len())
++                .map_err(|_| Error::Parse("Invalid compressed data length"))?
++        {
+             return Err(Error::Parse("Invalid compressed data length"));
+         }
+ 
++        cursor.set_position(end_of_compressed_data);
++
+         Ok(())
+     }
+ 
+@@ -323,120 +342,196 @@ impl AlzLocalFileHeader {
+         &mut self,
+         cursor: &std::io::Cursor<&Vec<u8>>,
+         files: &mut Vec<ExtractedFile>,
++        max_extracted_size: u64,
+     ) -> Result<(), Error> {
+-        #[allow(clippy::cast_possible_truncation)]
+-        let start: usize = self.start_of_compressed_data as usize;
+-        #[allow(clippy::cast_possible_truncation)]
+-        let end: usize = start + self.compressed_size as usize;
+-        if end >= cursor.get_ref().len() {
+-            return Err(Error::Extract);
+-        }
+-        let data: &[u8] = &cursor.get_ref().as_slice()[start..end];
++        let start: usize =
++            usize::try_from(self.start_of_compressed_data).map_err(|_| Error::Extract)?;
++        let len: usize = usize::try_from(self.compressed_size).map_err(|_| Error::Extract)?;
++        let end: usize = start.checked_add(len).ok_or(Error::Extract)?;
++        let data: &[u8] = cursor
++            .get_ref()
++            .as_slice()
++            .get(start..end)
++            .ok_or(Error::Extract)?;
+ 
+-        let mut inflater = InflateStream::new();
+         let mut out: Vec<u8> = Vec::<u8>::new();
+-        let mut n: usize = 0;
+-
+-        while n < data.len() {
+-            let res = inflater.update(&data[n..]);
+-            if let Ok((num_bytes_read, result)) = res {
+-                n += num_bytes_read;
+-                out.extend(result.iter().copied());
+-            } else {
+-                return Err(Error::Extract);
++        let mut decompressor = DeflateDecoder::new(data);
++        let mut buffer = [0u8; 8192];
++
++        loop {
++            let len = decompressor.read(&mut buffer).map_err(|_| {
++                debug!("Unable to decompress deflate data");
++                Error::Extract
++            })?;
++            if len == 0 {
++                break;
++            }
++
++            if let Some(needed) =
++                self.append_output(&mut out, &buffer[..len], max_extracted_size)?
++            {
++                self.push_file(out, files)?;
++                return Err(Error::ScanLimitExceeded(needed));
+             }
+         }
+ 
+-        self.write_file(&out, files);
++        self.push_file(out, files)
++    }
+ 
+-        Ok(())
++    fn append_output(
++        &self,
++        out: &mut Vec<u8>,
++        buffer: &[u8],
++        max_extracted_size: u64,
++    ) -> Result<Option<u64>, Error> {
++        let needed = out.len().checked_add(buffer.len()).ok_or(Error::Extract)?;
++        let needed_u64 = u64::try_from(needed).map_err(|_| Error::Extract)?;
++
++        if needed_u64 > max_extracted_size {
++            let current = u64::try_from(out.len()).map_err(|_| Error::Extract)?;
++            let remaining = max_extracted_size.saturating_sub(current);
++            let copy_len = usize::try_from(remaining)
++                .unwrap_or(usize::MAX)
++                .min(buffer.len());
++
++            out.try_reserve(copy_len).map_err(|_| Error::Alloc)?;
++            out.extend_from_slice(&buffer[..copy_len]);
++
++            return Ok(Some(needed_u64));
++        }
++
++        out.try_reserve(buffer.len()).map_err(|_| Error::Alloc)?;
++        out.extend_from_slice(buffer);
++
++        Ok(None)
+     }
+ 
+-    fn write_file(&mut self, buffer: &[u8], files: &mut Vec<ExtractedFile>) {
++    fn push_file(&mut self, data: Vec<u8>, files: &mut Vec<ExtractedFile>) -> Result<(), Error> {
++        if data.is_empty() {
++            return Ok(());
++        }
++
++        let mut name = String::new();
++        name.try_reserve(self.file_name.len())
++            .map_err(|_| Error::Alloc)?;
++        name.push_str(&self.file_name);
++
+         let extracted_file: ExtractedFile = ExtractedFile {
+-            name: Some(self.file_name.to_string()),
+-            data: buffer.to_vec(),
++            name: Some(name),
++            data,
+         };
+ 
+-        if 0 != extracted_file.data.len() {
+-            files.push(extracted_file);
+-        }
++        files.try_reserve(1).map_err(|_| Error::Alloc)?;
++        files.push(extracted_file);
++
++        Ok(())
++    }
++
++    fn write_file(&mut self, buffer: &[u8], files: &mut Vec<ExtractedFile>) -> Result<(), Error> {
++        let mut data: Vec<u8> = Vec::new();
++        data.try_reserve_exact(buffer.len())
++            .map_err(|_| Error::Alloc)?;
++        data.extend_from_slice(buffer);
++
++        self.push_file(data, files)
+     }
+ 
+     fn extract_file_nocomp(
+         &mut self,
+-        cursor: &mut std::io::Cursor<&Vec<u8>>,
++        cursor: &std::io::Cursor<&Vec<u8>>,
+         files: &mut Vec<ExtractedFile>,
++        max_extracted_size: u64,
+     ) -> Result<(), Error> {
+-        #[allow(clippy::cast_possible_truncation)]
+-        let idx0: usize = self.start_of_compressed_data as usize;
++        let idx0: usize =
++            usize::try_from(self.start_of_compressed_data).map_err(|_| Error::Extract)?;
+ 
+-        let mut len = self.compressed_size;
+         if self.compressed_size != self.uncompressed_size {
+-            debug!("Uncompressed file has different lengths for compressed vs uncompressed, using the shorter");
+-            if self.compressed_size > self.uncompressed_size {
+-                len = self.uncompressed_size;
+-            }
++            debug!("Uncompressed file has different lengths for compressed vs uncompressed, using the stored size");
+         }
+ 
+-        #[allow(clippy::cast_possible_truncation)]
+-        let idx1: usize = idx0 + len as usize;
+-        if idx1 > cursor.get_ref().len() {
+-            debug!("Invalid data length");
+-            return Err(Error::Extract);
++        let len: usize = usize::try_from(self.compressed_size).map_err(|_| Error::Extract)?;
++        let idx1: usize = idx0.checked_add(len).ok_or(Error::Extract)?;
++
++        let contents = cursor
++            .get_ref()
++            .as_slice()
++            .get(idx0..idx1)
++            .ok_or(Error::Extract)?;
++
++        let contents_len = u64::try_from(contents.len()).map_err(|_| Error::Extract)?;
++        if contents_len > max_extracted_size {
++            let copy_len = usize::try_from(max_extracted_size)
++                .unwrap_or(usize::MAX)
++                .min(contents.len());
++            self.write_file(&contents[..copy_len], files)?;
++            return Err(Error::ScanLimitExceeded(contents_len));
+         }
+ 
+-        let contents = &cursor.get_ref().as_slice()[idx0..idx1];
+-        cursor.set_position(idx1 as u64);
+-
+-        self.write_file(contents, files);
+-        Ok(())
++        self.write_file(contents, files)
+     }
+ 
+     fn extract_file_bzip2(
+         &mut self,
+         cursor: &std::io::Cursor<&Vec<u8>>,
+         files: &mut Vec<ExtractedFile>,
++        max_extracted_size: u64,
+     ) -> Result<(), Error> {
+-        #[allow(clippy::cast_possible_truncation)]
+-        let idx0: usize = self.start_of_compressed_data as usize;
+-        #[allow(clippy::cast_possible_truncation)]
+-        let idx1: usize = idx0 + self.compressed_size as usize;
++        let idx0: usize =
++            usize::try_from(self.start_of_compressed_data).map_err(|_| Error::Extract)?;
++        let len: usize = usize::try_from(self.compressed_size).map_err(|_| Error::Extract)?;
++        let idx1: usize = idx0.checked_add(len).ok_or(Error::Extract)?;
+ 
+-        let contents = &cursor.get_ref().as_slice()[idx0..idx1];
++        let contents = cursor
++            .get_ref()
++            .as_slice()
++            .get(idx0..idx1)
++            .ok_or(Error::Extract)?;
+ 
+-        /*
+-         * Create vector of the needed capacity.
+-         */
+         let mut out: Vec<u8> = Vec::new();
+-        for _i in 0..self.uncompressed_size {
+-            out.push(0);
++        let mut decompressor = DecoderReader::new(contents);
++        let mut buffer = [0u8; 8192];
++        loop {
++            let len = decompressor.read(&mut buffer).map_err(|_| {
++                debug!("Unable to decompress bz2 data");
++                Error::Extract
++            })?;
++            if len == 0 {
++                break;
++            }
++
++            if let Some(needed) =
++                self.append_output(&mut out, &buffer[..len], max_extracted_size)?
++            {
++                self.push_file(out, files)?;
++                return Err(Error::ScanLimitExceeded(needed));
++            }
+         }
+ 
+-        let mut decompressor = DecoderReader::new(contents);
+-        let ret = decompressor.read_exact(&mut out);
+-        if ret.is_err() {
+-            debug!("Unable to decompress bz2 data");
+-            return Err(Error::Extract);
++        if let Ok(uncompressed_size) = usize::try_from(self.uncompressed_size) {
++            if out.len() != uncompressed_size {
++                debug!(
++                    "Bzip2 file has different lengths for declared vs decompressed data, using the decompressed size"
++                );
++            }
+         }
+ 
+-        self.write_file(&out, files);
+-        Ok(())
++        self.push_file(out, files)
+     }
+ 
+     fn extract_file(
+         &mut self,
+         cursor: &mut std::io::Cursor<&Vec<u8>>,
+         files: &mut Vec<ExtractedFile>,
++        max_extracted_size: u64,
+     ) -> Result<(), Error> {
+         const ALZ_COMP_NOCOMP: u8 = 0;
+         const ALZ_COMP_BZIP2: u8 = 1;
+         const ALZ_COMP_DEFLATE: u8 = 2;
+ 
+         match self.compression_method {
+-            ALZ_COMP_NOCOMP => self.extract_file_nocomp(cursor, files),
+-            ALZ_COMP_BZIP2 => self.extract_file_bzip2(cursor, files),
+-            ALZ_COMP_DEFLATE => self.extract_file_deflate(cursor, files),
++            ALZ_COMP_NOCOMP => self.extract_file_nocomp(cursor, files, max_extracted_size),
++            ALZ_COMP_BZIP2 => self.extract_file_bzip2(cursor, files, max_extracted_size),
++            ALZ_COMP_DEFLATE => self.extract_file_deflate(cursor, files, max_extracted_size),
+             _ => Err(Error::Extract),
+         }
+     }
+@@ -448,9 +543,27 @@ pub struct ExtractedFile {
+     pub data: Vec<u8>,
+ }
+ 
++pub struct AlzFileMetadata<'a> {
++    pub file_name: &'a str,
++    pub compressed_size: u64,
++    pub uncompressed_size: u64,
++    pub is_encrypted: bool,
++    pub file_crc: u32,
++    pub filepos: usize,
++}
++
++pub struct AlzExtractionLimits {
++    pub max_file_size: u64,
++    pub max_total_size: u64,
++}
++
+ #[derive(Default)]
+ pub struct Alz {
+     pub embedded_files: Vec<ExtractedFile>,
++    pub file_limit_exceeded_size: Option<u64>,
++    pub total_limit_exceeded_size: Option<u64>,
++    extracted_size: u64,
++    parse_error: bool,
+ }
+ 
+ impl<'aa> Alz {
+@@ -462,29 +575,97 @@ impl<'aa> Alz {
+             .map_or(false, |n| ALZ_FILE_HEADER == n)
+     }
+ 
+-    fn parse_local_fileheader(&mut self, cursor: &mut std::io::Cursor<&Vec<u8>>) -> bool {
++    fn parse_local_fileheader<F>(
++        &mut self,
++        cursor: &mut std::io::Cursor<&Vec<u8>>,
++        filepos: &mut usize,
++        should_extract: &mut F,
++    ) -> Result<(), Error>
++    where
++        F: FnMut(&AlzFileMetadata<'_>) -> Option<AlzExtractionLimits>,
++    {
+         let mut local_fileheader = AlzLocalFileHeader::new();
+ 
+-        if let Err(err) = local_fileheader.parse(cursor) {
+-            debug!("{err}");
+-            return false;
+-        }
++        local_fileheader.parse(cursor)?;
+ 
+-        if let Err(err) = local_fileheader.is_supported() {
+-            debug!("{err}");
+-            return false;
+-        }
++        let metadata_filepos = *filepos;
++        *filepos = metadata_filepos.saturating_add(1);
+ 
+         if !local_fileheader.is_directory() {
+             /* The is_file flag doesn't appear to always be set, so we'll just assume it's a file if
+              * it's not marked as a directory.*/
+-            let res2 = local_fileheader.extract_file(cursor, &mut self.embedded_files);
+-            if res2.is_err() {
+-                return false;
++            let metadata = AlzFileMetadata {
++                file_name: &local_fileheader.file_name,
++                compressed_size: local_fileheader.compressed_size,
++                uncompressed_size: local_fileheader.uncompressed_size,
++                is_encrypted: local_fileheader.is_encrypted(),
++                file_crc: local_fileheader.file_crc,
++                filepos: metadata_filepos,
++            };
++
++            let limits = should_extract(&metadata);
++
++            if let Err(err) = local_fileheader.is_supported() {
++                debug!("{err}");
++                return Ok(());
++            }
++
++            let Some(limits) = limits else {
++                return Ok(());
++            };
++
++            let base_extracted_size = self.extracted_size;
++            let max_total_remaining = limits.max_total_size.saturating_sub(base_extracted_size);
++            let max_extracted_size = limits.max_file_size.min(max_total_remaining);
++            let files_start = self.embedded_files.len();
++
++            match local_fileheader.extract_file(
++                cursor,
++                &mut self.embedded_files,
++                max_extracted_size,
++            ) {
++                Ok(()) => {}
++                Err(Error::ScanLimitExceeded(needed)) => {
++                    debug!(
++                        "ALZ file {:?} exceeded extraction size limits. Scanning truncated content.",
++                        local_fileheader.file_name
++                    );
++                    if needed > limits.max_file_size
++                        && self
++                            .file_limit_exceeded_size
++                            .map_or(true, |current| current < needed)
++                    {
++                        self.file_limit_exceeded_size = Some(needed);
++                    }
++
++                    let total_needed = base_extracted_size.saturating_add(needed);
++                    if total_needed > limits.max_total_size
++                        && self
++                            .total_limit_exceeded_size
++                            .map_or(true, |current| current < total_needed)
++                    {
++                        self.total_limit_exceeded_size = Some(total_needed);
++                    }
++                }
++                Err(Error::Extract) => {
++                    debug!(
++                        "Failed to extract ALZ file {:?}. Continuing with next entry.",
++                        local_fileheader.file_name
++                    );
++                }
++                Err(err) => return Err(err),
++            }
++
++            for file in &self.embedded_files[files_start..] {
++                let Ok(file_size) = u64::try_from(file.data.len()) else {
++                    self.extracted_size = u64::MAX;
++                    break;
++                };
++                self.extracted_size = self.extracted_size.saturating_add(file_size);
+             }
+         }
+ 
+-        true
++        Ok(())
+     }
+ 
+     #[allow(clippy::unused_self)]
+@@ -503,16 +684,39 @@ impl<'aa> Alz {
+     pub const fn new() -> Self {
+         Self {
+             embedded_files: Vec::new(),
++            file_limit_exceeded_size: None,
++            total_limit_exceeded_size: None,
++            extracted_size: 0,
++            parse_error: false,
+         }
+     }
+ 
++    pub const fn has_parse_error(&self) -> bool {
++        self.parse_error
++    }
++
+     /// # Errors
+     /// Will return `Error::Parse` if file headers are not correct or are inconsistent.
+     pub fn from_bytes(bytes: &'aa [u8]) -> Result<Self, Error> {
++        Self::from_bytes_with_filter(bytes, |_| {
++            Some(AlzExtractionLimits {
++                max_file_size: u64::MAX,
++                max_total_size: u64::MAX,
++            })
++        })
++    }
++
++    /// # Errors
++    /// Will return `Error::Parse` if file headers are not correct or are inconsistent.
++    pub fn from_bytes_with_filter<F>(bytes: &'aa [u8], mut should_extract: F) -> Result<Self, Error>
++    where
++        F: FnMut(&AlzFileMetadata<'_>) -> Option<AlzExtractionLimits>,
++    {
+         let binding = bytes.to_vec();
+         let mut cursor = Cursor::new(&binding);
+ 
+         let mut alz: Self = Self::new();
++        let mut filepos: usize = 1;
+ 
+         if !alz.is_alz(&mut cursor) {
+             return Err(Error::Parse("No ALZ file header"));
+@@ -531,9 +735,21 @@ impl<'aa> Alz {
+ 
+             match sig {
+                 ALZ_LOCAL_FILE_HEADER => {
+-                    if alz.parse_local_fileheader(&mut cursor) {
+-                        continue;
++                    match alz.parse_local_fileheader(&mut cursor, &mut filepos, &mut should_extract)
++                    {
++                        Ok(()) => {}
++                        Err(Error::Alloc) => return Err(Error::Alloc),
++                        Err(err) => {
++                            if filepos == 1 {
++                                return Err(err);
++                            }
++
++                            debug!("Failed to parse ALZ local file header: {err}");
++                            alz.parse_error = true;
++                            break;
++                        }
+                     }
++                    continue;
+                 }
+                 ALZ_CENTRAL_DIRECTORY_HEADER => {
+                     if alz.parse_central_directoryheader(&mut cursor) {
+@@ -546,7 +762,14 @@ impl<'aa> Alz {
+                 }
+                 _ => {
+                     #[allow(clippy::uninlined_format_args)]
+-                    return Err(Error::UnrecognizedSig(format!("{:x}", sig)));
++                    let err = Error::UnrecognizedSig(format!("{:x}", sig));
++                    if filepos == 1 {
++                        return Err(err);
++                    }
++
++                    debug!("Failed to parse ALZ archive: {err}");
++                    alz.parse_error = true;
++                    break;
+                 }
+             }
+         }
+@@ -554,3 +777,226 @@ impl<'aa> Alz {
+         Ok(alz)
+     }
+ }
++
++#[cfg(test)]
++mod tests {
++    use super::*;
++    use std::io::Write;
++
++    fn append_local_file(
++        alz: &mut Vec<u8>,
++        name: &str,
++        compression_method: u8,
++        uncompressed_size: u8,
++        data: &[u8],
++    ) {
++        append_local_entry(
++            alz,
++            name,
++            AlzFileAttribute::File as u8,
++            0x10,
++            compression_method,
++            uncompressed_size,
++            data,
++        );
++    }
++
++    fn append_local_entry(
++        alz: &mut Vec<u8>,
++        name: &str,
++        file_attribute: u8,
++        file_descriptor: u8,
++        compression_method: u8,
++        uncompressed_size: u8,
++        data: &[u8],
++    ) {
++        let name = name.as_bytes();
++        let name_len = u16::try_from(name.len()).unwrap();
++        let compressed_size = u8::try_from(data.len()).unwrap();
++
++        alz.extend_from_slice(&ALZ_LOCAL_FILE_HEADER.to_le_bytes());
++        alz.extend_from_slice(&name_len.to_le_bytes());
++        alz.push(file_attribute);
++        alz.extend_from_slice(&0u32.to_le_bytes());
++        alz.push(file_descriptor);
++        alz.push(0);
++        alz.push(compression_method);
++        alz.push(0);
++        alz.extend_from_slice(&0u32.to_le_bytes());
++        alz.push(compressed_size);
++        alz.push(uncompressed_size);
++        alz.extend_from_slice(name);
++        if file_descriptor & 0x01 != 0 {
++            alz.extend_from_slice(&[0; ALZ_ENCR_HEADER_LEN as usize]);
++        }
++        alz.extend_from_slice(data);
++    }
++
++    fn extraction_limits() -> AlzExtractionLimits {
++        AlzExtractionLimits {
++            max_file_size: u64::MAX,
++            max_total_size: u64::MAX,
++        }
++    }
++
++    fn raw_deflate(data: &[u8]) -> Vec<u8> {
++        let mut encoder =
++            flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
++        encoder.write_all(data).unwrap();
++        encoder.finish().unwrap()
++    }
++
++    #[test]
++    fn extraction_error_does_not_stop_later_entries() {
++        const ALZ_COMP_NOCOMP: u8 = 0;
++        const ALZ_COMP_BZIP2: u8 = 1;
++
++        let mut bytes = Vec::new();
++        bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes());
++        bytes.extend_from_slice(&0u32.to_le_bytes());
++        append_local_file(&mut bytes, "bad.bz2", ALZ_COMP_BZIP2, 1, &[0]);
++        append_local_file(&mut bytes, "good.txt", ALZ_COMP_NOCOMP, 4, b"good");
++        bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes());
++
++        let alz = Alz::from_bytes_with_filter(&bytes, |_| Some(extraction_limits())).unwrap();
++
++        assert_eq!(alz.embedded_files.len(), 1);
++        assert_eq!(alz.embedded_files[0].name.as_deref(), Some("good.txt"));
++        assert_eq!(alz.embedded_files[0].data, b"good");
++    }
++
++    #[test]
++    fn later_parse_error_preserves_earlier_extracted_entries() {
++        const ALZ_COMP_NOCOMP: u8 = 0;
++
++        let mut bytes = Vec::new();
++        bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes());
++        bytes.extend_from_slice(&0u32.to_le_bytes());
++        append_local_file(&mut bytes, "good.txt", ALZ_COMP_NOCOMP, 4, b"good");
++        bytes.extend_from_slice(&ALZ_LOCAL_FILE_HEADER.to_le_bytes());
++
++        let alz = Alz::from_bytes_with_filter(&bytes, |_| Some(extraction_limits())).unwrap();
++
++        assert!(alz.has_parse_error());
++        assert_eq!(alz.embedded_files.len(), 1);
++        assert_eq!(alz.embedded_files[0].name.as_deref(), Some("good.txt"));
++        assert_eq!(alz.embedded_files[0].data, b"good");
++    }
++
++    #[test]
++    fn deflate_limit_uses_decompressed_size_not_header() {
++        const ALZ_COMP_DEFLATE: u8 = 2;
++        let payload = vec![b'A'; 1024];
++        let compressed = raw_deflate(&payload);
++        assert!(compressed.len() <= u8::MAX.into());
++
++        let mut bytes = Vec::new();
++        bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes());
++        bytes.extend_from_slice(&0u32.to_le_bytes());
++        append_local_file(&mut bytes, "big.txt", ALZ_COMP_DEFLATE, 1, &compressed);
++        bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes());
++
++        let alz = Alz::from_bytes_with_filter(&bytes, |_| {
++            Some(AlzExtractionLimits {
++                max_file_size: 64,
++                max_total_size: u64::MAX,
++            })
++        })
++        .unwrap();
++
++        assert_eq!(alz.file_limit_exceeded_size, Some(payload.len() as u64));
++        assert_eq!(alz.embedded_files.len(), 1);
++        assert_eq!(alz.embedded_files[0].name.as_deref(), Some("big.txt"));
++        assert_eq!(alz.embedded_files[0].data.as_slice(), &payload[..64]);
++    }
++
++    #[test]
++    fn total_limit_does_not_set_per_file_limit() {
++        const ALZ_COMP_NOCOMP: u8 = 0;
++        let payload = vec![b'A'; 60];
++
++        let mut bytes = Vec::new();
++        bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes());
++        bytes.extend_from_slice(&0u32.to_le_bytes());
++        append_local_file(
++            &mut bytes,
++            "a",
++            ALZ_COMP_NOCOMP,
++            payload.len() as u8,
++            &payload,
++        );
++        append_local_file(
++            &mut bytes,
++            "b",
++            ALZ_COMP_NOCOMP,
++            payload.len() as u8,
++            &payload,
++        );
++        bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes());
++
++        let alz = Alz::from_bytes_with_filter(&bytes, |_| {
++            Some(AlzExtractionLimits {
++                max_file_size: 64,
++                max_total_size: 100,
++            })
++        })
++        .unwrap();
++
++        assert_eq!(alz.file_limit_exceeded_size, None);
++        assert_eq!(alz.total_limit_exceeded_size, Some(120));
++        assert_eq!(alz.embedded_files.len(), 2);
++        assert_eq!(alz.embedded_files[0].data.len(), 60);
++        assert_eq!(alz.embedded_files[1].data.len(), 40);
++    }
++
++    #[test]
++    fn metadata_file_positions_are_one_based_and_include_skipped_entries() {
++        const ALZ_COMP_NOCOMP: u8 = 0;
++
++        let mut bytes = Vec::new();
++        bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes());
++        bytes.extend_from_slice(&0u32.to_le_bytes());
++        append_local_entry(
++            &mut bytes,
++            "dir/",
++            AlzFileAttribute::Directory as u8,
++            0x10,
++            ALZ_COMP_NOCOMP,
++            0,
++            b"",
++        );
++        append_local_entry(
++            &mut bytes,
++            "encrypted.bin",
++            AlzFileAttribute::File as u8,
++            0x11,
++            ALZ_COMP_NOCOMP,
++            0,
++            b"",
++        );
++        append_local_file(&mut bytes, "first.txt", ALZ_COMP_NOCOMP, 5, b"first");
++        append_local_file(&mut bytes, "second.txt", ALZ_COMP_NOCOMP, 6, b"second");
++        bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes());
++
++        let mut metadata_positions = Vec::new();
++        let alz = Alz::from_bytes_with_filter(&bytes, |metadata| {
++            metadata_positions.push((
++                metadata.file_name.to_owned(),
++                metadata.filepos,
++                metadata.is_encrypted,
++            ));
++            None
++        })
++        .unwrap();
++
++        assert!(alz.embedded_files.is_empty());
++        assert_eq!(
++            metadata_positions,
++            vec![
++                ("encrypted.bin".to_owned(), 2, true),
++                ("first.txt".to_owned(), 3, false),
++                ("second.txt".to_owned(), 4, false),
++            ]
++        );
++    }
++}
+diff --git a/libclamav_rust/src/scanners.rs b/libclamav_rust/src/scanners.rs
+index be8c52acd1..862b0f46e1 100644
+--- a/libclamav_rust/src/scanners.rs
++++ b/libclamav_rust/src/scanners.rs
+@@ -21,6 +21,7 @@
+  */
+ 
+ use std::{
++    convert::TryFrom,
+     ffi::{c_char, CString},
+     io::Read,
+     panic,
+@@ -33,14 +34,17 @@ use libc::c_void;
+ use log::{debug, error, warn};
+ 
+ use crate::{
+-    alz::Alz,
++    alz::{Alz, AlzExtractionLimits, Error as AlzError},
+     ctx,
+     onenote::OneNote,
+     sys::{
+-        cl_error_t, cl_error_t_CL_EFORMAT, cl_error_t_CL_ERROR, cl_error_t_CL_SUCCESS, cli_ctx,
++        cl_error_t, cl_error_t_CL_EFORMAT, cl_error_t_CL_EMAXSIZE, cl_error_t_CL_EMEM,
++        cl_error_t_CL_ERROR, cl_error_t_CL_SUCCESS, cl_error_t_CL_VIRUS, cli_ctx,
+         cli_magic_scan_buff,
+     },
+-    util::{check_scan_limits, scan_archive_metadata},
++    util::{
++        append_potentially_unwanted_if_heur_exceedsmax, check_scan_limits, scan_archive_metadata,
++    },
+ };
+ 
+ /// Rust wrapper of libclamav's cli_magic_scan_buff() function.
+@@ -307,6 +311,30 @@ pub unsafe extern "C" fn scan_lha_lzh(ctx: *mut cli_ctx) -> cl_error_t {
+     cl_error_t_CL_SUCCESS
+ }
+ 
++unsafe fn alz_extraction_limits(ctx: *mut cli_ctx) -> AlzExtractionLimits {
++    if ctx.is_null() || (*ctx).engine.is_null() {
++        return AlzExtractionLimits {
++            max_file_size: u64::MAX,
++            max_total_size: u64::MAX,
++        };
++    }
++
++    let engine = &*(*ctx).engine;
++
++    AlzExtractionLimits {
++        max_file_size: if engine.maxfilesize == 0 {
++            u64::MAX
++        } else {
++            engine.maxfilesize
++        },
++        max_total_size: if engine.maxscansize == 0 {
++            u64::MAX
++        } else {
++            engine.maxscansize.saturating_sub((*ctx).scansize)
++        },
++    }
++}
++
+ /// Scan an Alz file for attachments
+ ///
+ /// # Safety
+@@ -333,14 +361,89 @@ pub unsafe extern "C" fn cli_scanalz(ctx: *mut cli_ctx) -> cl_error_t {
+         }
+     };
+ 
+-    let alz = match Alz::from_bytes(file_bytes) {
+-        Ok(x) => x,
+-        Err(err) => {
+-            error!("Failed to parse Alz file: {}", err.to_string());
+-            return cl_error_t_CL_ERROR;
++    let mut alz_metadata_ret = cl_error_t_CL_SUCCESS;
++    let alz_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
++        Alz::from_bytes_with_filter(file_bytes, |metadata| {
++            if check_scan_limits("ALZ", ctx, 0, 0, 0) != cl_error_t_CL_SUCCESS {
++                debug!("Exceeded scan limits. Bailing out.");
++                return None;
++            }
++
++            match (
++                usize::try_from(metadata.compressed_size),
++                usize::try_from(metadata.uncompressed_size),
++            ) {
++                (Ok(compressed_size), Ok(uncompressed_size)) => {
++                    let metadata_ret = scan_archive_metadata(
++                        ctx,
++                        metadata.file_name,
++                        compressed_size,
++                        uncompressed_size,
++                        metadata.is_encrypted,
++                        metadata.filepos,
++                        metadata.file_crc as i32,
++                    );
++                    if metadata_ret == cl_error_t_CL_VIRUS {
++                        alz_metadata_ret = metadata_ret;
++                        debug!(
++                            "ALZ file {:?} metadata did not pass scan checks. Skipping extraction.",
++                            metadata.file_name
++                        );
++                        return None;
++                    } else if metadata_ret != cl_error_t_CL_SUCCESS {
++                        debug!(
++                            "ALZ file {:?} metadata scan failed with {}. Continuing extraction.",
++                            metadata.file_name, metadata_ret
++                        );
++                    }
++                }
++                _ => debug!(
++                    "ALZ file {:?} metadata sizes are too large for this platform. Continuing extraction.",
++                    metadata.file_name
++                ),
++            }
++
++            Some(alz_extraction_limits(ctx))
++        })
++    }));
++
++    let alz = match alz_result {
++        Ok(Ok(x)) => x,
++        Ok(Err(AlzError::Alloc)) => {
++            debug!("Failed to allocate memory when parsing ALZ archive");
++            return cl_error_t_CL_EMEM;
++        }
++        Ok(Err(err)) => {
++            debug!("Failed to parse Alz file: {}", err.to_string());
++            return cl_error_t_CL_EFORMAT;
++        }
++        Err(_) => {
++            debug!("Panic occurred when trying to parse ALZ archive");
++            return cl_error_t_CL_EFORMAT;
+         }
+     };
+ 
++    if alz_metadata_ret != cl_error_t_CL_SUCCESS {
++        return alz_metadata_ret;
++    }
++
++    if let Some(needed) = alz.file_limit_exceeded_size {
++        let ret = check_scan_limits("ALZ", ctx, needed, 0, 0);
++        if ret != cl_error_t_CL_SUCCESS && ret != cl_error_t_CL_EMAXSIZE {
++            return ret;
++        }
++    }
++
++    if alz.total_limit_exceeded_size.is_some() {
++        let ret = append_potentially_unwanted_if_heur_exceedsmax(
++            ctx,
++            "Heuristics.Limits.Exceeded.MaxScanSize",
++        );
++        if ret != cl_error_t_CL_SUCCESS {
++            return ret;
++        }
++    }
++
+     for i in 0..alz.embedded_files.len() {
+         let ret = magic_scan(
+             ctx,
+@@ -352,5 +455,9 @@ pub unsafe extern "C" fn cli_scanalz(ctx: *mut cli_ctx) -> cl_error_t {
+         }
+     }
+ 
++    if alz.has_parse_error() {
++        return cl_error_t_CL_EFORMAT;
++    }
++
+     cl_error_t_CL_SUCCESS
+ }
+diff --git a/libclamav_rust/src/sys.rs b/libclamav_rust/src/sys.rs
+index 399ec438a4..9e36a11709 100644
+--- a/libclamav_rust/src/sys.rs
++++ b/libclamav_rust/src/sys.rs
+@@ -795,6 +795,12 @@ extern "C" {
+         virname: *const ::std::os::raw::c_char,
+     ) -> cl_error_t;
+ }
++extern "C" {
++    pub fn cli_append_potentially_unwanted_if_heur_exceedsmax(
++        ctx: *mut cli_ctx,
++        vname: *mut ::std::os::raw::c_char,
++    );
++}
+ extern "C" {
+     pub fn cli_warnmsg(str_: *const ::std::os::raw::c_char, ...);
+ }
+diff --git a/libclamav_rust/src/util.rs b/libclamav_rust/src/util.rs
+index 086fdf2786..5a459919f3 100644
+--- a/libclamav_rust/src/util.rs
++++ b/libclamav_rust/src/util.rs
+@@ -22,7 +22,7 @@
+ 
+ use std::{ffi::CStr, fs::File};
+ 
+-use log::error;
++use log::{debug, error};
+ 
+ use crate::sys;
+ 
+@@ -85,7 +85,7 @@ pub unsafe fn check_scan_limits(
+     let module_name = match std::ffi::CString::new(module_name) {
+         Ok(name) => name,
+         Err(_) => {
+-            error!("Invalid module_name: {}", module_name);
++            error!("Invalid module_name: {:?}", module_name);
+             return sys::cl_error_t_CL_EFORMAT;
+         }
+     };
+@@ -93,6 +93,31 @@ pub unsafe fn check_scan_limits(
+     unsafe { sys::cli_checklimits(module_name.as_ptr(), ctx, need1, need2, need3) }
+ }
+ 
++/// Append an exceeds-max heuristic alert or metadata entry.
++///
++/// # Safety
++///
++/// ctx must be a valid pointer to a clamav scan context structure
++///
++pub unsafe fn append_potentially_unwanted_if_heur_exceedsmax(
++    ctx: *mut sys::cli_ctx,
++    virname: &str,
++) -> sys::cl_error_t {
++    let virname = match std::ffi::CString::new(virname) {
++        Ok(name) => name,
++        Err(_) => {
++            error!("Invalid virname: {:?}", virname);
++            return sys::cl_error_t_CL_EFORMAT;
++        }
++    };
++
++    unsafe {
++        sys::cli_append_potentially_unwanted_if_heur_exceedsmax(ctx, virname.as_ptr().cast_mut());
++    }
++
++    sys::cl_error_t_CL_SUCCESS
++}
++
+ /// Scan archive metadata.
+ ///
+ /// # Safety
+@@ -111,7 +136,7 @@ pub unsafe fn scan_archive_metadata(
+     let module_name = match std::ffi::CString::new(filename) {
+         Ok(name) => name,
+         Err(_) => {
+-            error!("Invalid module_name: {}", filename);
++            debug!("Invalid archive metadata filename: {:?}", filename);
+             return sys::cl_error_t_CL_EFORMAT;
+         }
+     };
+diff --git a/unit_tests/clamscan/alz_test.py b/unit_tests/clamscan/alz_test.py
+index 78b294b339..995c4a5156 100644
+--- a/unit_tests/clamscan/alz_test.py
++++ b/unit_tests/clamscan/alz_test.py
+@@ -4,12 +4,58 @@
+ Run clamscan tests.
+ """
+ 
++import hashlib
+ import sys
++import zlib
+ 
+ sys.path.append('../unit_tests')
+ import testcase
+ 
+ 
++ALZ_FILE_HEADER = 0x015A4C41
++ALZ_LOCAL_FILE_HEADER = 0x015A4C42
++ALZ_END_OF_CENTRAL_DIRECTORY_HEADER = 0x025A4C43
++ALZ_ATTR_DIRECTORY = 0x10
++ALZ_ATTR_FILE = 0x20
++ALZ_COMP_NOCOMP = 0
++ALZ_COMP_BZIP2 = 1
++ALZ_COMP_DEFLATE = 2
++ALZ_ENCR_HEADER_LEN = 12
++
++
++def append_alz_file(
++    alz,
++    name,
++    compression_method,
++    uncompressed_size,
++    data,
++    file_attribute=ALZ_ATTR_FILE,
++    file_descriptor=0x10,
++):
++    name = name.encode('utf-8')
++
++    alz.extend(ALZ_LOCAL_FILE_HEADER.to_bytes(4, 'little'))
++    alz.extend(len(name).to_bytes(2, 'little'))
++    alz.append(file_attribute)
++    alz.extend((0).to_bytes(4, 'little'))  # file time/date
++    alz.append(file_descriptor)
++    alz.append(0)  # unknown
++    alz.append(compression_method)
++    alz.append(0)  # unknown
++    alz.extend((0).to_bytes(4, 'little'))  # crc
++    alz.append(len(data))
++    alz.append(uncompressed_size)
++    alz.extend(name)
++    if file_descriptor & 0x01:
++        alz.extend(b'\x00' * ALZ_ENCR_HEADER_LEN)
++    alz.extend(data)
++
++
++def raw_deflate(data):
++    compressor = zlib.compressobj(wbits=-15)
++    return compressor.compress(data) + compressor.flush()
++
++
+ class TC(testcase.TestCase):
+     @classmethod
+     def setUpClass(cls):
+@@ -116,6 +162,281 @@ def test_uncompressed_with_binary(self):
+         ]
+         self.verify_output(output.out, expected=expected_results)
+ 
++    def test_extraction_error_does_not_abort_archive_scan(self):
++        self.step_name('Test alz scan continues after a malformed member')
++
++        payload = b'alz-later-member'
++        testfile = TC.path_tmp / 'bad-then-good.alz'
++        path_db = TC.path_tmp / 'bad-then-good.hdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(alz, 'bad.bz2', ALZ_COMP_BZIP2, 1, b'\x00')
++        append_alz_file(alz, 'good.txt', ALZ_COMP_NOCOMP, len(payload), payload)
++        alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little'))
++
++        testfile.write_bytes(alz)
++        path_db.write_text(
++            '{}:{}:ALZ_LATER_FILE\n'.format(hashlib.sha1(payload).hexdigest(), len(payload))
++        )
++
++        command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format(
++            valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'ALZ_LATER_FILE.UNOFFICIAL FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results)
++
++    def test_later_parse_error_does_not_skip_earlier_member_scan(self):
++        self.step_name('Test alz scan checks earlier members before later parse errors')
++
++        payload = b'alz-earlier-member'
++        testfile = TC.path_tmp / 'good-then-bad-header.alz'
++        path_db = TC.path_tmp / 'good-then-bad-header.hdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(alz, 'good.txt', ALZ_COMP_NOCOMP, len(payload), payload)
++        alz.extend(ALZ_LOCAL_FILE_HEADER.to_bytes(4, 'little'))
++
++        testfile.write_bytes(alz)
++        path_db.write_text(
++            '{}:{}:ALZ_EARLIER_FILE\n'.format(hashlib.sha1(payload).hexdigest(), len(payload))
++        )
++
++        command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format(
++            valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'ALZ_EARLIER_FILE.UNOFFICIAL FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results)
++
++    def test_deflate_limit_uses_decompressed_size(self):
++        self.step_name('Test alz deflate scan limits use decompressed output size')
++
++        truncated_payload = b'A' * 64
++        payload = b'A' * 4096
++        compressed = raw_deflate(payload)
++        assert len(compressed) <= 255
++
++        testfile = TC.path_tmp / 'deflate-limit.alz'
++        path_db = TC.path_tmp / 'deflate-limit.hdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(alz, 'x', ALZ_COMP_DEFLATE, 1, compressed)
++        alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little'))
++        assert len(alz) <= 64
++
++        testfile.write_bytes(alz)
++        path_db.write_text(
++            '{}:{}:ALZ_DEFLATE_LIMIT\n'.format(
++                hashlib.sha1(truncated_payload).hexdigest(),
++                len(truncated_payload),
++            )
++        )
+ 
++        command = (
++            '{valgrind} {valgrind_args} {clamscan} --max-filesize=64 '
++            '--max-scansize=4096 -d {path_db} {testfile}'
++        ).format(
++            valgrind=TC.valgrind,
++            valgrind_args=TC.valgrind_args,
++            clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'ALZ_DEFLATE_LIMIT.UNOFFICIAL FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results)
+ 
++    def test_inflated_header_size_does_not_skip_extraction(self):
++        self.step_name('Test alz scan ignores inflated header size for extraction gating')
++
++        payload = b'alz-small-hit'
++        testfile = TC.path_tmp / 'inflated-header-size.alz'
++        path_db = TC.path_tmp / 'inflated-header-size.hdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(alz, 'x', ALZ_COMP_NOCOMP, 80, payload)
++        alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little'))
++
++        testfile.write_bytes(alz)
++        path_db.write_text(
++            '{}:{}:ALZ_INFLATED_HEADER_SIZE\n'.format(
++                hashlib.sha1(payload).hexdigest(),
++                len(payload),
++            )
++        )
+ 
++        command = (
++            '{valgrind} {valgrind_args} {clamscan} --max-filesize=64 '
++            '--max-scansize=1024 -d {path_db} {testfile}'
++        ).format(
++            valgrind=TC.valgrind,
++            valgrind_args=TC.valgrind_args,
++            clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'ALZ_INFLATED_HEADER_SIZE.UNOFFICIAL FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results)
++
++    def test_total_limit_does_not_report_max_file_size(self):
++        self.step_name('Test alz archive total limit is not reported as max file size')
++
++        payload = b'A' * 60
++        testfile = TC.path_tmp / 'total-limit.alz'
++        path_db = TC.path_tmp / 'total-limit.hdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(alz, 'a', ALZ_COMP_NOCOMP, len(payload), payload)
++        append_alz_file(alz, 'b', ALZ_COMP_NOCOMP, len(payload), payload)
++        alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little'))
++        assert len(alz) <= 200
++
++        testfile.write_bytes(alz)
++        path_db.write_text(
++            '{}:{}:ALZ_UNUSED\n'.format(
++                hashlib.sha1(b'not-present').hexdigest(),
++                len(b'not-present'),
++            )
++        )
++
++        command = (
++            '{valgrind} {valgrind_args} {clamscan} --alert-exceeds-max=yes '
++            '--max-filesize=200 --max-scansize=100 -d {path_db} {testfile}'
++        ).format(
++            valgrind=TC.valgrind,
++            valgrind_args=TC.valgrind_args,
++            clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'Heuristics.Limits.Exceeded.MaxScanSize FOUND',
++        ]
++        unexpected_results = [
++            'Heuristics.Limits.Exceeded.MaxFileSize FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results, unexpected=unexpected_results)
++
++    def test_metadata_filepos_counts_skipped_entries_from_one(self):
++        self.step_name('Test alz metadata file position counts skipped entries from one')
++
++        payload = b'alz-filepos-member'
++        testfile = TC.path_tmp / 'filepos-skipped.alz'
++        path_db = TC.path_tmp / 'filepos-skipped.cdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(
++            alz,
++            'dir/',
++            ALZ_COMP_NOCOMP,
++            0,
++            b'',
++            file_attribute=ALZ_ATTR_DIRECTORY,
++        )
++        append_alz_file(
++            alz,
++            'encrypted.bin',
++            ALZ_COMP_NOCOMP,
++            0,
++            b'',
++            file_descriptor=0x11,
++        )
++        append_alz_file(alz, 'good.txt', ALZ_COMP_NOCOMP, len(payload), payload)
++        alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little'))
++
++        testfile.write_bytes(alz)
++        path_db.write_text('ALZ_FILEPOS_TEST:CL_TYPE_ALZ:*:good.txt:*:*:0:3:*:*\n')
++
++        command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format(
++            valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'ALZ_FILEPOS_TEST.UNOFFICIAL FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results)
++
++    def test_encrypted_metadata_matches_before_extraction_skip(self):
++        self.step_name('Test alz encrypted entries are matched before extraction skip')
++
++        testfile = TC.path_tmp / 'encrypted-metadata.alz'
++        path_db = TC.path_tmp / 'encrypted-metadata.cdb'
++
++        alz = bytearray()
++        alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little'))
++        alz.extend((0).to_bytes(4, 'little'))
++        append_alz_file(
++            alz,
++            'encrypted.bin',
++            ALZ_COMP_NOCOMP,
++            0,
++            b'',
++            file_descriptor=0x11,
++        )
++        alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little'))
++
++        testfile.write_bytes(alz)
++        path_db.write_text(
++            'ALZ_ENCRYPTED_METADATA_TEST:CL_TYPE_ALZ:*:encrypted.bin:*:*:1:1:*:*\n'
++        )
++
++        command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format(
++            valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan,
++            path_db=path_db,
++            testfile=testfile,
++        )
++        output = self.execute_command(command)
++
++        assert output.ec == 1  # virus
++
++        expected_results = [
++            'ALZ_ENCRYPTED_METADATA_TEST.UNOFFICIAL FOUND',
++        ]
++        self.verify_output(output.out, expected=expected_results)
diff --git a/package/clamav/clamav.mk b/package/clamav/clamav.mk
index f6a5e0edf4..8e5d9278cc 100644
--- a/package/clamav/clamav.mk
+++ b/package/clamav/clamav.mk
@@ -39,6 +39,9 @@  CLAMAV_IGNORE_CVES += CVE-2026-20216
 # 0005-libclamav-fix-fsg-section-loop-underflow-51.patch
 CLAMAV_IGNORE_CVES += CVE-2026-20214
 
+# 0006-fix-alz-parser-robustness-and-scan-coverage-63.patch
+CLAMAV_IGNORE_CVES += CVE-2026-20243
+
 CLAMAV_DEPENDENCIES = \
 	bzip2 \
 	host-pkgconf \