server/inspect/src/walk.rs
//! The walk: open every archive level, account every unpacked byte, run the
//! member checks.
//!
//! Members are STREAMED. Only the first [`magic::HEAD`] bytes of a member are
//! held to classify it; a member that is itself an archive is read into memory
//! (bounded by `max_nested_bytes`) so it can be opened; everything else is
//! drained through the byte budget and dropped. Every decompressing reader is a
//! [`Capped`], so a bomb is refused after at most the cap plus one buffer.
use std::cell::Cell;
use std::io::{self, Cursor, Read, Write};
use std::rc::Rc;
use crate::magic::{self, Kind, HEAD};
use crate::{
malware, ExecPolicy, FindingKind, Findings, Format, Inspector, Limits, Rule, Severity,
};
/// Which limit a [`Capped`] reader tripped.
#[derive(Debug, Clone, Copy)]
enum Hit {
Ratio { compressed: u64, cap: u64 },
Entry,
Total,
}
#[derive(Default)]
struct Budget {
expanded: Cell<u64>,
hit: Cell<Option<Hit>>,
}
/// A decompressing reader under the ratio, entry and total limits.
struct Capped<R> {
inner: R,
read: u64,
compressed: u64,
ratio_cap: u64,
entry_cap: u64,
total_cap: u64,
budget: Rc<Budget>,
}
impl<R: Read> Capped<R> {
fn new(inner: R, compressed: u64, limits: &Limits, budget: Rc<Budget>) -> Self {
Self {
inner,
read: 0,
compressed,
ratio_cap: limits
.ratio_floor_bytes
.max(compressed.saturating_mul(limits.max_ratio)),
entry_cap: limits.max_entry_bytes,
total_cap: limits.max_total_expanded,
budget,
}
}
}
impl<R: Read> Read for Capped<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
self.read += n as u64;
let total = self.budget.expanded.get() + n as u64;
self.budget.expanded.set(total);
let hit = if self.read > self.ratio_cap && self.ratio_cap < self.entry_cap {
Some(Hit::Ratio {
compressed: self.compressed,
cap: self.ratio_cap,
})
} else if self.read > self.entry_cap {
Some(Hit::Entry)
} else if total > self.total_cap {
Some(Hit::Total)
} else {
None
};
if let Some(h) = hit {
self.budget.hit.set(Some(h));
return Err(io::Error::other("holger-inspect: expansion limit"));
}
Ok(n)
}
}
/// The xz decode's sink: the decoded bytes in memory, under the same ratio, entry and
/// total limits as a [`Capped`] reader, and under `max_nested_bytes` (xz is decoded
/// whole: lzma-rs has no streaming reader).
struct CappedSink {
out: Vec<u8>,
compressed: u64,
ratio_cap: u64,
entry_cap: u64,
total_cap: u64,
nested_cap: u64,
nested_hit: bool,
budget: Rc<Budget>,
}
impl CappedSink {
fn new(compressed: u64, limits: &Limits, budget: Rc<Budget>) -> Self {
Self {
out: Vec::new(),
compressed,
ratio_cap: limits
.ratio_floor_bytes
.max(compressed.saturating_mul(limits.max_ratio)),
entry_cap: limits.max_entry_bytes,
total_cap: limits.max_total_expanded,
nested_cap: limits.max_nested_bytes,
nested_hit: false,
budget,
}
}
}
impl Write for CappedSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let read = self.out.len() as u64 + buf.len() as u64;
let total = self.budget.expanded.get() + buf.len() as u64;
self.budget.expanded.set(total);
let hit = if read > self.ratio_cap && self.ratio_cap < self.entry_cap {
Some(Hit::Ratio {
compressed: self.compressed,
cap: self.ratio_cap,
})
} else if read > self.entry_cap {
Some(Hit::Entry)
} else if total > self.total_cap {
Some(Hit::Total)
} else {
None
};
if let Some(h) = hit {
self.budget.hit.set(Some(h));
return Err(io::Error::other("holger-inspect: expansion limit"));
}
if read > self.nested_cap {
self.nested_hit = true;
return Err(io::Error::other("holger-inspect: max_nested_bytes"));
}
self.out.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// The name of the one file inside a compressed `base`: `x.tgz` holds `x.tar`, `x.gz`
/// holds `x`; `pairs` are `(suffix, what replaces it)`, lower case.
fn inner_name(path: &str, pairs: &[(&str, &str)]) -> String {
let base = path.rsplit('!').next().unwrap_or(path);
let lower = base.to_ascii_lowercase();
for (suffix, with) in pairs {
if lower.ends_with(suffix) {
return format!("{}{with}", &base[..base.len() - suffix.len()]);
}
}
String::new()
}
const GZ_NAMES: &[(&str, &str)] = &[(".tgz", ".tar"), (".gz", "")];
const BZ2_NAMES: &[(&str, &str)] = &[(".tbz2", ".tar"), (".tbz", ".tar"), (".bz2", "")];
const ZST_NAMES: &[(&str, &str)] = &[(".tzst", ".tar"), (".zst", ""), (".zstd", "")];
const XZ_NAMES: &[(&str, &str)] = &[(".txz", ".tar"), (".xz", "")];
/// Pure: where an rpm's payload starts: after the 96-byte lead, the signature header
/// (padded to 8 bytes) and the main header. `None` when the headers do not read.
fn rpm_payload_offset(b: &[u8]) -> Option<usize> {
let header_len = |at: usize| -> Option<usize> {
if b.get(at..at + 4)? != [0x8e, 0xad, 0xe8, 0x01] {
return None;
}
let nindex = u32::from_be_bytes(b.get(at + 8..at + 12)?.try_into().ok()?) as usize;
let hsize = u32::from_be_bytes(b.get(at + 12..at + 16)?.try_into().ok()?) as usize;
let len = 16usize
.checked_add(nindex.checked_mul(16)?)?
.checked_add(hsize)?;
(at.checked_add(len)? <= b.len()).then_some(len)
};
let sig: usize = 96;
let main = sig.checked_add(header_len(sig)?)?.checked_add(7)? & !7;
let payload = main.checked_add(header_len(main)?)?;
(payload <= b.len()).then_some(payload)
}
/// What a visitor is handed for every member it may read: the member's own name,
/// its path through the archives (`artifact!dir/file`), and its bytes.
pub type Visit<'v> = &'v mut dyn FnMut(&str, &str, &[u8]);
struct Walker<'a, 'v> {
/// Every non-archive member (at most `max_scan_member_bytes`), when set.
visit: Option<Visit<'v>>,
/// Members too large to hand to the visitor.
skipped: u64,
limits: &'a Limits,
policy: &'a ExecPolicy,
format: Format,
scanners: &'a [std::sync::Arc<dyn malware::MalwareScanner>],
member_scan: bool,
budget: Rc<Budget>,
out: Findings,
/// A limit that ends the walk (total expansion, member count) was hit.
stopped: bool,
}
pub(crate) fn walk(insp: &Inspector, bytes: &[u8], format: Format, name: &str) -> Findings {
walk_visiting(insp, bytes, format, name, None).0
}
/// The walk, handing every member that is not itself an archive to `visit`
/// (the artifact itself too, when it is no archive). Returns the findings and
/// how many members were too large to hand over.
pub(crate) fn walk_visiting(
insp: &Inspector,
bytes: &[u8],
format: Format,
name: &str,
visit: Option<Visit<'_>>,
) -> (Findings, u64) {
let detected = magic::detect(bytes);
let mut w = Walker {
visit,
skipped: 0,
limits: insp.limits(),
policy: insp.policy(format),
format,
scanners: insp.scanners(),
member_scan: insp.scanners().iter().any(|s| s.reads_members()),
budget: Rc::new(Budget::default()),
out: Findings::new(format, detected),
stopped: false,
};
let label = if name.is_empty() {
format!("<{}>", format.name())
} else {
name.to_string()
};
let expected = format
.expected_kinds()
.or_else(|| magic::kinds_for_name(name));
if let Some(exp) = expected {
if !exp.contains(&detected) {
w.out.push(
FindingKind::MimeMismatch,
Severity::Block,
&label,
format!(
"declared {} ({}) but the bytes are {}",
format.name(),
names(exp),
detected.name()
),
);
}
}
if !detected.is_unpacked() {
if let Some(v) = w.visit.as_mut() {
if bytes.len() as u64 <= w.limits.max_scan_member_bytes {
v(&label, &label, bytes);
} else {
w.skipped += 1;
}
}
}
w.open(bytes, &label, 0);
w.out.expanded_bytes = w.budget.expanded.get();
(w.out, w.skipped)
}
fn names(kinds: &[Kind]) -> String {
kinds
.iter()
.map(|k| k.name())
.collect::<Vec<_>>()
.join(" or ")
}
impl Walker<'_, '_> {
fn block(&mut self, kind: FindingKind, path: &str, detail: impl Into<String>) {
self.out.push(kind, Severity::Block, path, detail);
}
fn rule(&mut self, rule: Rule, kind: FindingKind, path: &str, detail: String) {
match rule {
Rule::Allow => {}
Rule::Warn => self.out.push(kind, Severity::Warn, path, detail),
Rule::Block => self.out.push(kind, Severity::Block, path, detail),
}
}
/// Open a container held in memory. `depth` is its own level (the artifact is 0).
fn open(&mut self, bytes: &[u8], path: &str, depth: u32) {
let kind = magic::detect(bytes);
if kind.is_unpacked() {
self.out.max_depth_seen = self.out.max_depth_seen.max(depth);
}
match kind {
Kind::Zip => self.zip(bytes, path, depth),
Kind::Tar => self.tar(Cursor::new(bytes), path, depth),
Kind::Gzip => self.gzip(bytes, path, depth),
Kind::Bzip2 => self.compressed(
bzip2::read::MultiBzDecoder::new(bytes),
bytes.len() as u64,
path,
depth,
BZ2_NAMES,
),
Kind::Zstd => match zstd::stream::read::Decoder::new(bytes) {
Ok(d) => self.compressed(d, bytes.len() as u64, path, depth, ZST_NAMES),
Err(e) => self.read_failed(path, depth, &e),
},
Kind::Xz => {
if let Some(plain) = self.unxz(bytes, path, depth) {
self.compressed(
Cursor::new(plain),
bytes.len() as u64,
path,
depth,
XZ_NAMES,
);
}
}
Kind::Ar => self.ar(bytes, path, depth),
Kind::Rpm => self.rpm(bytes, path, depth),
k if k.is_archive() => self.not_unpacked(k, path),
_ => {}
}
}
/// HOL-NCSC (NCSC P2): an archive the walk cannot open is BLOCKED, never passed.
fn not_unpacked(&mut self, k: Kind, path: &str) {
self.block(
FindingKind::NotUnpacked,
path,
format!(
"{} cannot be unpacked here, so its members cannot be inspected; it is blocked",
k.name()
),
);
}
/// The finding for a read that failed: a tripped limit, or a corrupt archive.
fn read_failed(&mut self, path: &str, depth: u32, e: &dyn std::fmt::Display) {
match self.budget.hit.take() {
Some(Hit::Ratio { compressed, cap }) => self.block(
FindingKind::ZipBomb,
path,
format!(
"expands past {cap} bytes from {compressed} compressed bytes (more than {}x)",
self.limits.max_ratio
),
),
Some(Hit::Entry) => self.block(
FindingKind::EntryTooLarge,
path,
format!(
"expands past max_entry_bytes ({})",
self.limits.max_entry_bytes
),
),
Some(Hit::Total) => {
self.block(
FindingKind::ExpansionLimit,
path,
format!(
"the artifact expands past max_total_expanded ({})",
self.limits.max_total_expanded
),
);
self.stopped = true;
}
None => {
// A corrupt top-level file that only LOOKS like an archive, in a
// format that promised none, cannot hide members: warn.
let sev = if depth == 0 && self.format == Format::Generic {
Severity::Warn
} else {
Severity::Block
};
self.out.push(
FindingKind::CorruptArchive,
sev,
path,
format!("cannot be read: {e}"),
);
}
}
}
fn count_entry(&mut self, path: &str) -> bool {
self.out.entries += 1;
if self.out.entries > self.limits.max_entries {
self.block(
FindingKind::TooManyEntries,
path,
format!(
"more than max_entries ({}) members",
self.limits.max_entries
),
);
self.stopped = true;
return false;
}
true
}
fn check_name(&mut self, name: &str, mpath: &str) {
let b = name.as_bytes();
if name.contains('\0') {
self.block(
FindingKind::PathTraversal,
mpath,
"NUL byte in the member name",
);
}
if name.starts_with('/')
|| name.starts_with('\\')
|| (b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic())
{
self.block(
FindingKind::AbsolutePath,
mpath,
format!("absolute member path {name:?}"),
);
}
if name.split(['/', '\\']).any(|s| s == "..") {
self.block(
FindingKind::PathTraversal,
mpath,
format!("member path {name:?} climbs out with '..'"),
);
}
}
/// A symlink (relative to its own directory) or a hard link (relative to the
/// archive root) must stay inside the archive.
fn check_link(&mut self, name: &str, target: &str, hard: bool, mpath: &str) {
let what = if hard { "hard link" } else { "symlink" };
let tb = target.as_bytes();
if target.starts_with('/') || target.starts_with('\\') || (tb.len() >= 2 && tb[1] == b':') {
self.block(
FindingKind::SymlinkEscape,
mpath,
format!("{what} to the absolute path {target:?}"),
);
return;
}
let mut stack: Vec<&str> = Vec::new();
if !hard {
let segs: Vec<&str> = name
.split(['/', '\\'])
.filter(|s| !s.is_empty() && *s != ".")
.collect();
stack.extend(segs.iter().take(segs.len().saturating_sub(1)));
}
for seg in target.split(['/', '\\']) {
match seg {
"" | "." => {}
".." => {
if stack.pop().is_none() {
self.block(
FindingKind::SymlinkEscape,
mpath,
format!("{what} to {target:?} leaves the archive root"),
);
return;
}
}
s => stack.push(s),
}
}
}
/// The checks on what a member IS: its name against its bytes, and the
/// format's rule for binaries and scripts.
///
/// `true`: the member may be a Windows script launcher, which only its size decides; the
/// caller reads it ([`Self::launcher`]) and no finding is made here.
fn check_kind(&mut self, head: &[u8], kind: Kind, name: &str, mpath: &str) -> bool {
if let Some(exp) = magic::kinds_for_name(name) {
if !exp.contains(&kind) {
let disguised = kind.is_native() || kind == Kind::Script || kind.is_archive();
let sev = if disguised {
Severity::Block
} else {
Severity::Warn
};
self.out.push(
FindingKind::MimeMismatch,
sev,
mpath,
format!(
"named like {} but the bytes are {}",
names(exp),
kind.name()
),
);
}
}
if kind.is_native() {
let lib = magic::is_library(head, kind, name);
let base = name.rsplit('/').next().unwrap_or(name);
if !lib
&& kind == Kind::Pe
&& self.policy.windows_launchers.is_some()
&& crate::WINDOWS_LAUNCHER_NAMES.contains(&base.to_ascii_lowercase().as_str())
{
return true;
}
let (rule, what) = if lib {
(self.policy.libraries, "native library")
} else {
(self.policy.executables, "native executable")
};
let detail = format!(
"{what} ({}) in a {} — policy says {rule:?}",
kind.name(),
self.format.name()
);
self.rule(rule, FindingKind::Executable, mpath, detail);
} else if kind == Kind::Script && !self.policy.in_script_dir(name) {
let rule = self.policy.scripts;
let detail = format!(
"#! script in a {} outside its script directories — policy says {rule:?}",
self.format.name()
);
self.rule(rule, FindingKind::Script, mpath, detail);
}
false
}
/// **A PE `*.exe` where the policy has a launcher rule** (lane HOL-310-CHECK): read whole up
/// to [`crate::WINDOWS_LAUNCHER_MAX_BYTES`]. Within it, the `windows_launchers` rule, and the
/// malware scanners read it as any member; past it, the `executables` rule (no launcher is
/// that big).
fn launcher(&mut self, head: Vec<u8>, r: &mut dyn Read, name: &str, mpath: &str, depth: u32) {
let cap = crate::WINDOWS_LAUNCHER_MAX_BYTES;
let Some(buf) = self.buffer(head, r, cap, mpath, depth) else {
let rule = self.policy.executables;
let detail = format!(
"native executable (PE binary) in a {}, larger than a Windows script launcher ({cap} bytes) — policy says {rule:?}",
self.format.name()
);
if self.member_scan || self.visit.is_some() {
self.skipped += 1;
}
return self.rule(rule, FindingKind::Executable, mpath, detail);
};
let rule = self
.policy
.windows_launchers
.unwrap_or(self.policy.executables);
let detail = format!(
"{} (PE .exe, {} bytes, at most {cap}) in a {}: a Windows install copies it next to a console script; nothing runs it on a build host or a Linux client; the malware scanners read it — policy says {rule:?}",
crate::WINDOWS_LAUNCHER_FINDING,
buf.len(),
self.format.name()
);
self.rule(rule, FindingKind::Executable, mpath, detail);
if self.member_scan {
malware::scan_member(self.scanners, &buf, name, mpath, &mut self.out);
}
if let Some(v) = self.visit.as_mut() {
v(name, mpath, &buf);
}
}
/// One member, streamed from `r`. `depth` is the member's own level.
fn member(&mut self, r: &mut dyn Read, name: &str, mpath: &str, depth: u32) {
let mut head = Vec::with_capacity(HEAD);
if let Err(e) = (&mut *r).take(HEAD as u64).read_to_end(&mut head) {
return self.read_failed(mpath, depth, &e);
}
self.member_with_head(head, r, name, mpath, depth);
}
fn member_with_head(
&mut self,
head: Vec<u8>,
r: &mut dyn Read,
name: &str,
mpath: &str,
depth: u32,
) {
if head.is_empty() {
return;
}
let kind = magic::detect(&head);
if self.check_kind(&head, kind, name, mpath) {
return self.launcher(head, r, name, mpath, depth);
}
if kind.is_archive() && !kind.is_unpacked() {
self.not_unpacked(kind, mpath);
return self.drain(r, mpath, depth);
}
if kind.is_unpacked() {
if depth > self.limits.max_depth {
self.block(
FindingKind::NestingTooDeep,
mpath,
format!(
"archive nested {depth} levels deep; max_depth is {}",
self.limits.max_depth
),
);
return self.drain(r, mpath, depth);
}
let Some(buf) = self.buffer(head, r, self.limits.max_nested_bytes, mpath, depth) else {
self.block(
FindingKind::NestedTooLarge,
mpath,
format!(
"nested archive larger than max_nested_bytes ({})",
self.limits.max_nested_bytes
),
);
return;
};
self.open(&buf, mpath, depth);
} else if self.member_scan || self.visit.is_some() {
match self.buffer(head, r, self.limits.max_scan_member_bytes, mpath, depth) {
Some(buf) => {
if self.member_scan {
malware::scan_member(self.scanners, &buf, name, mpath, &mut self.out);
}
if let Some(v) = self.visit.as_mut() {
v(name, mpath, &buf);
}
}
None => self.skipped += 1,
}
} else {
self.drain(r, mpath, depth);
}
}
/// The whole member in memory, or `None` (the rest drained) past `cap`.
fn buffer(
&mut self,
mut head: Vec<u8>,
r: &mut dyn Read,
cap: u64,
mpath: &str,
depth: u32,
) -> Option<Vec<u8>> {
let room = (cap + 1).saturating_sub(head.len() as u64);
if let Err(e) = (&mut *r).take(room).read_to_end(&mut head) {
self.read_failed(mpath, depth, &e);
return None;
}
if head.len() as u64 > cap {
self.drain(r, mpath, depth);
return None;
}
Some(head)
}
fn drain(&mut self, r: &mut dyn Read, mpath: &str, depth: u32) {
if let Err(e) = io::copy(r, &mut io::sink()) {
self.read_failed(mpath, depth, &e);
}
}
fn zip(&mut self, bytes: &[u8], path: &str, depth: u32) {
let mut ar = match zip::ZipArchive::new(Cursor::new(bytes)) {
Ok(a) => a,
Err(e) => return self.read_failed(path, depth, &e),
};
for i in 0..ar.len() {
if self.stopped {
return;
}
let (name, is_dir, encrypted, compressed, mode) = match ar.by_index_raw(i) {
Ok(f) => (
String::from_utf8_lossy(f.name_raw()).into_owned(),
f.is_dir(),
f.encrypted(),
f.compressed_size(),
f.unix_mode(),
),
Err(e) => {
self.read_failed(&format!("{path}!#{i}"), depth + 1, &e);
continue;
}
};
let mpath = format!("{path}!{name}");
if !self.count_entry(&mpath) {
return;
}
self.check_name(&name, &mpath);
if is_dir {
continue;
}
if encrypted {
self.block(
FindingKind::Encrypted,
&mpath,
"encrypted member; its contents cannot be inspected",
);
continue;
}
let f = match ar.by_index(i) {
Ok(f) => f,
Err(e) => {
self.read_failed(&mpath, depth + 1, &e);
continue;
}
};
let mut r = Capped::new(f, compressed, self.limits, Rc::clone(&self.budget));
if mode.is_some_and(|m| m & 0o170000 == 0o120000) {
let mut target = Vec::new();
match (&mut r).take(4096).read_to_end(&mut target) {
Ok(_) => {
self.check_link(&name, &String::from_utf8_lossy(&target), false, &mpath)
}
Err(e) => self.read_failed(&mpath, depth + 1, &e),
}
continue;
}
self.member(&mut r, &name, &mpath, depth + 1);
}
}
fn tar<R: Read>(&mut self, reader: R, path: &str, depth: u32) {
let mut ar = tar::Archive::new(reader);
let entries = match ar.entries() {
Ok(e) => e,
Err(e) => return self.read_failed(path, depth, &e),
};
for ent in entries {
if self.stopped {
return;
}
let mut ent = match ent {
Ok(e) => e,
Err(e) => return self.read_failed(path, depth, &e),
};
let name = String::from_utf8_lossy(&ent.path_bytes()).into_owned();
let mpath = format!("{path}!{name}");
if !self.count_entry(&mpath) {
return;
}
self.check_name(&name, &mpath);
let et = ent.header().entry_type();
if et.is_symlink() || et.is_hard_link() {
let target = ent
.link_name_bytes()
.map(|t| String::from_utf8_lossy(&t).into_owned())
.unwrap_or_default();
if matches!(self.format, Format::OciImage | Format::Deb | Format::Rpm)
&& depth > 0
&& et.is_symlink()
{
// A layer is a root file system: its symlinks resolve inside the
// container's root, so they are recorded, not refused.
self.out.push(FindingKind::SymlinkEscape, Severity::Info, &mpath, format!("symlink to {target:?} inside an image layer (resolved in the container's root)"));
continue;
}
self.check_link(&name, &target, et.is_hard_link(), &mpath);
continue;
}
if !et.is_file() {
continue;
}
if ent.size() > self.limits.max_entry_bytes {
self.block(
FindingKind::EntryTooLarge,
&mpath,
format!(
"{} bytes; max_entry_bytes is {}",
ent.size(),
self.limits.max_entry_bytes
),
);
continue;
}
self.member(&mut ent, &name, &mpath, depth + 1);
if self.budget.hit.get().is_some() {
// The stream under this tar broke; the next header cannot be read.
return;
}
}
}
/// A gzip around a tar is transparent: a tar.gz is ONE level. The one file
/// in any other `.gz` is a member one level down.
fn gzip(&mut self, bytes: &[u8], path: &str, depth: u32) {
self.compressed(
flate2::read::MultiGzDecoder::new(bytes),
bytes.len() as u64,
path,
depth,
GZ_NAMES,
);
}
/// A compressed stream (gzip, bzip2, zstd, xz decoded): around a tar it is
/// transparent, a `.tar.gz` is ONE level; any other file in it is a member one
/// level down, so a chain of compression layers cannot nest without bound.
fn compressed<R: Read>(
&mut self,
dec: R,
compressed: u64,
path: &str,
depth: u32,
names: &[(&str, &str)],
) {
let mut r = Capped::new(dec, compressed, self.limits, Rc::clone(&self.budget));
let mut head = Vec::with_capacity(HEAD);
if let Err(e) = (&mut r).take(HEAD as u64).read_to_end(&mut head) {
return self.read_failed(path, depth, &e);
}
if magic::detect(&head) == Kind::Tar {
return self.tar(Cursor::new(head).chain(r), path, depth);
}
let inner = inner_name(path, names);
let mpath = format!("{path}!{inner}");
self.member_with_head(head, &mut r, &inner, &mpath, depth + 1);
}
/// **xz, decoded whole in memory** (lzma-rs, pure Rust, has no streaming reader): under
/// the expansion limits, and under `max_nested_bytes`. The decoded bytes are accounted
/// here, once. `None`: refused (the finding is written).
fn unxz(&mut self, bytes: &[u8], path: &str, depth: u32) -> Option<Vec<u8>> {
let mut sink = CappedSink::new(bytes.len() as u64, self.limits, Rc::clone(&self.budget));
let r = lzma_rs::xz_decompress(&mut io::BufReader::new(bytes), &mut sink);
if sink.nested_hit {
self.block(
FindingKind::NestedTooLarge,
path,
format!(
"the xz stream decodes past max_nested_bytes ({}); xz is decoded in memory",
self.limits.max_nested_bytes
),
);
return None;
}
match r {
Ok(()) => {
// Read back from memory: the expansion is already counted.
let plain = std::mem::take(&mut sink.out);
self.budget.expanded.set(
self.budget
.expanded
.get()
.saturating_sub(plain.len() as u64),
);
Some(plain)
}
Err(e) => {
self.read_failed(path, depth, &e);
None
}
}
}
/// **An `ar` archive** (a .deb, a static library), held in memory: every member,
/// GNU (`//` name table) and BSD (`#1/<len>`) long names, the symbol table skipped.
fn ar(&mut self, b: &[u8], path: &str, depth: u32) {
let mut at = 8;
let mut long_names: &[u8] = &[];
while at < b.len() {
if self.stopped {
return;
}
if b[at..].iter().all(|c| *c == b'\n') {
return;
}
let Some(h) = b.get(at..at + 60) else {
return self.read_failed(path, depth, &"a truncated ar member header");
};
let size = std::str::from_utf8(&h[48..58])
.ok()
.and_then(|s| s.trim().parse::<usize>().ok());
let (Some(size), b"`\n") = (size, &h[58..60]) else {
return self.read_failed(path, depth, &"an ar member header that does not read");
};
let start = at + 60;
let Some(mut data) = start.checked_add(size).and_then(|end| b.get(start..end)) else {
return self.read_failed(path, depth, &"an ar member that runs past the archive");
};
at = start + size + (size & 1);
let raw = std::str::from_utf8(&h[..16]).unwrap_or("").trim_end();
let name = match raw {
"/" | "/SYM64/" | "__.SYMDEF" | "__.SYMDEF SORTED" => continue,
"//" => {
long_names = data;
continue;
}
n if n.len() > 1
&& n.starts_with('/')
&& n[1..].bytes().all(|c| c.is_ascii_digit()) =>
{
let off: usize = n[1..].parse().unwrap_or(usize::MAX);
let rest = long_names.get(off..).unwrap_or_default();
let end = rest.iter().position(|c| *c == b'\n').unwrap_or(rest.len());
String::from_utf8_lossy(&rest[..end])
.trim_end_matches('/')
.to_string()
}
n if n.starts_with("#1/") => {
let len: usize = n[3..].trim().parse().unwrap_or(usize::MAX);
let Some(nm) = data.get(..len) else {
return self.read_failed(
path,
depth,
&"a BSD ar long name that runs past its member",
);
};
let name = String::from_utf8_lossy(nm)
.trim_end_matches('\0')
.to_string();
data = &data[len..];
name
}
n => n.trim_end_matches('/').to_string(),
};
let mpath = format!("{path}!{name}");
if !self.count_entry(&mpath) {
return;
}
self.check_name(&name, &mpath);
self.member(&mut Cursor::new(data), &name, &mpath, depth + 1);
}
}
/// **An rpm**: its payload (a cpio `newc` stream, gzip, xz, zstd or bzip2, or plain)
/// is opened and every member inspected; the payload's compression is transparent, as
/// a tar's gzip is. A payload in no format the walk opens is blocked.
fn rpm(&mut self, b: &[u8], path: &str, depth: u32) {
let Some(off) = rpm_payload_offset(b) else {
return self.block(
FindingKind::CorruptArchive,
path,
"the rpm's lead or headers do not read, so its payload cannot be inspected",
);
};
let payload = &b[off..];
let compressed = payload.len() as u64;
match magic::detect(payload) {
Kind::Gzip => self.cpio(flate2::read::MultiGzDecoder::new(payload), compressed, path, depth),
Kind::Bzip2 => self.cpio(bzip2::read::MultiBzDecoder::new(payload), compressed, path, depth),
Kind::Zstd => match zstd::stream::read::Decoder::new(payload) {
Ok(d) => self.cpio(d, compressed, path, depth),
Err(e) => self.read_failed(path, depth, &e),
},
Kind::Xz => {
if let Some(plain) = self.unxz(payload, path, depth) {
self.cpio(Cursor::new(plain), compressed, path, depth);
}
}
_ if payload.starts_with(b"07070") => self.cpio(payload, compressed, path, depth),
k => self.block(
FindingKind::NotUnpacked,
path,
format!("the rpm's payload is {}, which cannot be unpacked here, so its members cannot be inspected; it is blocked", k.name()),
),
}
}
/// A cpio `newc` stream (`070701`/`070702`), streamed: every regular file is a member,
/// every symlink checked, the rest skipped.
fn cpio<R: Read>(&mut self, dec: R, compressed: u64, path: &str, depth: u32) {
let mut r = Capped::new(dec, compressed, self.limits, Rc::clone(&self.budget));
let mut read = 0u64;
loop {
if self.stopped {
return;
}
let mut h = [0u8; 110];
if let Err(e) = r.read_exact(&mut h) {
return self.read_failed(path, depth, &e);
}
read += 110;
if &h[..6] != b"070701" && &h[..6] != b"070702" {
return self.read_failed(path, depth, &"a cpio member header that is not newc");
}
let field = |i: usize| {
std::str::from_utf8(&h[6 + i * 8..14 + i * 8])
.ok()
.and_then(|s| u64::from_str_radix(s, 16).ok())
};
let (Some(mode), Some(size), Some(namesize)) = (field(1), field(6), field(11)) else {
return self.read_failed(path, depth, &"a cpio member header that does not read");
};
if namesize == 0 || namesize > 4096 {
return self.read_failed(path, depth, &"a cpio member name of no or absurd length");
}
let mut raw = vec![0u8; namesize as usize];
if let Err(e) = r.read_exact(&mut raw) {
return self.read_failed(path, depth, &e);
}
read += namesize;
if skip(&mut r, (4 - read % 4) % 4, &mut read).is_err() {
return self.read_failed(path, depth, &"a truncated cpio member name");
}
let name = String::from_utf8_lossy(raw.strip_suffix(&[0]).unwrap_or(&raw)).into_owned();
if name == "TRAILER!!!" {
return;
}
let mpath = format!("{path}!{name}");
if !self.count_entry(&mpath) {
return;
}
self.check_name(&name, &mpath);
let ftype = mode & 0o170000;
if ftype == 0o120000 {
let mut target = Vec::new();
if let Err(e) = (&mut r).take(size.min(4096)).read_to_end(&mut target) {
return self.read_failed(&mpath, depth + 1, &e);
}
read += target.len() as u64;
if size > 4096 {
self.block(
FindingKind::CorruptArchive,
&mpath,
"a symlink target longer than 4096 bytes",
);
if skip(&mut r, size - 4096, &mut read).is_err() {
return self.read_failed(path, depth, &"a truncated cpio member");
}
} else if matches!(self.format, Format::OciImage | Format::Deb | Format::Rpm)
&& depth == 0
{
// An rpm's payload is a root file system: its symlinks resolve in the
// installed system's root, so an absolute one is recorded, not refused.
let t = String::from_utf8_lossy(&target).into_owned();
self.out.push(FindingKind::SymlinkEscape, Severity::Info, &mpath, format!("symlink to {t:?} in a package payload (resolved in the installed root)"));
} else {
self.check_link(&name, &String::from_utf8_lossy(&target), false, &mpath);
}
} else if ftype == 0o100000 {
if size > self.limits.max_entry_bytes {
self.block(
FindingKind::EntryTooLarge,
&mpath,
format!(
"{size} bytes; max_entry_bytes is {}",
self.limits.max_entry_bytes
),
);
if skip(&mut r, size, &mut read).is_err() {
return self.read_failed(path, depth, &"a truncated cpio member");
}
} else {
let mut body = (&mut r).take(size);
self.member(&mut body, &name, &mpath, depth + 1);
if self.budget.hit.get().is_some() {
return;
}
// What the member left (a stopped read) is drained to the next header.
if io::copy(&mut body, &mut io::sink()).is_err() {
return;
}
read += size;
}
} else if skip(&mut r, size, &mut read).is_err() {
return self.read_failed(path, depth, &"a truncated cpio member");
}
if skip(&mut r, (4 - read % 4) % 4, &mut read).is_err() {
return self.read_failed(path, depth, &"a truncated cpio member");
}
}
}
}
/// Read and drop `n` bytes of `r`, counting them into `read`.
fn skip(r: &mut dyn Read, n: u64, read: &mut u64) -> io::Result<()> {
let got = io::copy(&mut r.take(n), &mut io::sink())?;
*read += got;
if got < n {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "truncated"));
}
Ok(())
}