guests/static-scan/src/archive.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! Walking a package archive without trusting it. Heimdall is a defensive
//! sandbox; this is the hostile parsing the guest exists for.
//!
//! The kind is read from the magic bytes, never from a name: gzip (tar
//! inside: npm `.tgz`, PyPI sdists, `.crate`) or zip (wheels, zip sdists).
//! Every member name is judged ([`judge_name`]); only manifests the checks
//! read are kept in memory ([`wanted`]), each up to `max-manifest-bytes`.
//! Unpacked bytes and member counts are capped; a cap that fires makes the
//! walk incomplete (`over_limits`), never silently clean.
use std::io::{self, Read};
use heimdall_scan::{Finding, Severity};
use crate::params::Limits;
use crate::zip;
/// Finding ids of the archive's own shape.
pub mod ids {
pub const PATH_TRAVERSAL: &str = "HEIMDALL-SCAN-ARCHIVE-PATH-TRAVERSAL";
pub const LINK_ESCAPE: &str = "HEIMDALL-SCAN-ARCHIVE-LINK-ESCAPE";
pub const NAME_NOT_UTF8: &str = "HEIMDALL-SCAN-ARCHIVE-NAME-NOT-UTF8";
pub const NAME_CONTROL: &str = "HEIMDALL-SCAN-ARCHIVE-NAME-CONTROL";
pub const DEEP_NESTING: &str = "HEIMDALL-SCAN-ARCHIVE-DEEP-NESTING";
pub const SPECIAL_FILE: &str = "HEIMDALL-SCAN-ARCHIVE-SPECIAL-FILE";
pub const DUPLICATE_NAME: &str = "HEIMDALL-SCAN-ARCHIVE-DUPLICATE-NAME";
pub const ZIP_OVERLAP: &str = "HEIMDALL-SCAN-ARCHIVE-ZIP-OVERLAP";
pub const ZIP_NAME_MISMATCH: &str = "HEIMDALL-SCAN-ARCHIVE-ZIP-NAME-MISMATCH";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
TarGz,
Zip,
}
/// A manifest kept for the checks.
#[derive(Debug, Clone)]
pub struct Kept {
pub name: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, Default)]
pub struct Walk {
pub kind: Option<Kind>,
pub findings: Vec<Finding>,
pub kept: Vec<Kept>,
/// Every member name (UTF-8 ones), in order, for the layout questions
/// (is this a wheel?).
pub names: Vec<String>,
/// The walk stopped or skipped something at a cap.
pub over_limits: Option<String>,
/// The archive is not one the guest reads, or it is corrupt.
pub unreadable: Option<String>,
}
impl Walk {
pub(crate) fn finding(&mut self, id: &str, severity: Severity, summary: String, path: &[u8]) {
self.findings.push(Finding {
id: id.into(),
severity,
summary,
path: Some(shown(path)),
});
}
pub(crate) fn over(&mut self, why: String) {
if self.over_limits.is_none() {
self.over_limits = Some(why);
}
}
}
/// A name as it may appear in a report: lossy UTF-8, control characters
/// escaped, at most 256 characters.
pub fn shown(name: &[u8]) -> String {
let s = String::from_utf8_lossy(name);
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i == 256 {
out.push('…');
break;
}
if c.is_control() {
out.extend(c.escape_default());
} else {
out.push(c);
}
}
out
}
/// The manifests a check reads: `<top>/package.json`, `<top>/setup.py`,
/// `<top>/pyproject.toml`, `<top>/setup.cfg`, `<x>.dist-info/WHEEL`, and a
/// top-level `*.pth` (a wheel's startup hook).
pub fn wanted(name: &str) -> bool {
let parts: Vec<&str> = name.split('/').filter(|p| !p.is_empty()).collect();
match parts.as_slice() {
[file] => file.ends_with(".pth"),
[dir, file] => {
matches!(
*file,
"package.json" | "setup.py" | "pyproject.toml" | "setup.cfg"
) || (*file == "WHEEL" && dir.ends_with(".dist-info"))
}
_ => false,
}
}
/// What a member's name says about the archive. Returns the clean UTF-8 name
/// when it may be kept.
pub(crate) fn judge_name(w: &mut Walk, raw: &[u8], limits: &Limits) -> Option<String> {
let Ok(name) = std::str::from_utf8(raw) else {
w.finding(
ids::NAME_NOT_UTF8,
Severity::Medium,
"an archive member name is not UTF-8 (a scanner and an installer may read it differently)".into(),
raw,
);
return None;
};
if name.chars().any(char::is_control) {
w.finding(
ids::NAME_CONTROL,
Severity::Medium,
"an archive member name holds control characters".into(),
raw,
);
return None;
}
if escapes(name) {
w.finding(
ids::PATH_TRAVERSAL,
Severity::High,
"an archive member is named outside the package root (absolute, `..`, a drive or a backslash)".into(),
raw,
);
return None;
}
let depth = name
.split('/')
.filter(|p| !p.is_empty() && *p != ".")
.count();
if depth > limits.max_depth {
w.finding(
ids::DEEP_NESTING,
Severity::Medium,
format!(
"an archive member is {depth} directories deep (limit {})",
limits.max_depth
),
raw,
);
return None;
}
let clean = name.trim_start_matches("./").to_owned();
w.names.push(clean.clone());
Some(clean)
}
/// Absolute, `..`, a backslash or a Windows drive: the name leaves the root.
fn escapes(name: &str) -> bool {
name.starts_with('/')
|| name.contains('\\')
|| name.split('/').any(|p| p == "..")
|| name.as_bytes().get(1) == Some(&b':')
}
/// A link target that leaves the package root, from a member at `name`.
pub(crate) fn link_escapes(name: &str, target: &[u8]) -> bool {
let Ok(t) = std::str::from_utf8(target) else {
return true;
};
if t.starts_with('/') || t.contains('\\') || t.as_bytes().get(1) == Some(&b':') {
return true;
}
// Walk from the member's directory.
let mut depth: i64 = name.split('/').filter(|p| !p.is_empty()).count() as i64 - 1;
for p in t.split('/') {
match p {
"" | "." => {}
".." => {
depth -= 1;
if depth < 0 {
return true;
}
}
_ => depth += 1,
}
}
false
}
fn note_duplicates(w: &mut Walk) {
let mut seen = std::collections::BTreeSet::new();
let mut dups = Vec::new();
for n in &w.names {
if !seen.insert(n.as_str()) {
dups.push(n.clone());
}
}
dups.dedup();
for d in dups {
w.finding(
ids::DUPLICATE_NAME,
Severity::Medium,
"two archive members share a name (the installer keeps one, a scanner may read the other)".into(),
d.as_bytes(),
);
}
}
/// Walk `bytes` under `limits`.
pub fn walk(bytes: &[u8], limits: &Limits) -> Walk {
let mut w = Walk::default();
if bytes.starts_with(&[0x1f, 0x8b]) {
w.kind = Some(Kind::TarGz);
walk_tar_gz(&mut w, bytes, limits);
} else if zip::looks_like_zip(bytes) {
w.kind = Some(Kind::Zip);
zip::walk(&mut w, bytes, limits);
} else {
w.unreadable = Some("the artifact is neither gzip'd tar nor zip".into());
}
note_duplicates(&mut w);
w
}
/// A reader that counts what it hands out and fails past a cap.
struct Capped<R> {
inner: R,
left: u64,
hit: bool,
}
impl<R: Read> Read for Capped<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.left == 0 {
// One byte more tells a stream that ends exactly at the cap from
// one that goes on.
let mut probe = [0u8; 1];
return match self.inner.read(&mut probe)? {
0 => Ok(0),
_ => {
self.hit = true;
Err(io::Error::other("unpacked size over the limit"))
}
};
}
let n = buf.len().min(self.left.min(usize::MAX as u64) as usize);
let got = self.inner.read(&mut buf[..n])?;
self.left -= got as u64;
Ok(got)
}
}
fn walk_tar_gz(w: &mut Walk, bytes: &[u8], limits: &Limits) {
let gz = flate2::read::MultiGzDecoder::new(bytes);
let capped = Capped {
inner: gz,
left: limits.max_unpacked_bytes,
hit: false,
};
let mut ar = tar::Archive::new(capped);
let mut count: u64 = 0;
let result: io::Result<()> = (|| {
for entry in ar.entries()? {
let mut e = entry?;
count += 1;
if count > limits.max_entries {
return Err(io::Error::other(format!(
"more than {} archive members",
limits.max_entries
)));
}
let raw = e.path_bytes().into_owned();
let Some(name) = judge_name(w, &raw, limits) else {
continue;
};
let ty = e.header().entry_type();
if ty.is_symlink() || ty.is_hard_link() {
let target = e
.link_name_bytes()
.map(|c| c.into_owned())
.unwrap_or_default();
if link_escapes(&name, &target) || (ty.is_hard_link() && escapes_hard(&target)) {
w.finding(
ids::LINK_ESCAPE,
Severity::High,
format!(
"an archive link points outside the package root (to {})",
shown(&target)
),
&raw,
);
}
continue;
}
if ty.is_character_special() || ty.is_block_special() || ty.is_fifo() {
w.finding(
ids::SPECIAL_FILE,
Severity::Medium,
"the archive holds a device or FIFO".into(),
&raw,
);
continue;
}
if !(ty.is_file() || ty.is_contiguous()) || !wanted(&name) {
continue;
}
let size = e.header().size().unwrap_or(u64::MAX);
if size > limits.max_manifest_bytes {
w.over(format!(
"manifest {} is {size} bytes (limit {})",
shown(&raw),
limits.max_manifest_bytes
));
continue;
}
let mut buf = Vec::with_capacity(size as usize);
(&mut e)
.take(limits.max_manifest_bytes + 1)
.read_to_end(&mut buf)?;
w.kept.push(Kept { name, bytes: buf });
}
Ok(())
})();
// The rest of the stream is read too, so the gzip CRC-32 and length of
// every member are checked (a flipped byte never reads as a clean
// package) and bytes hidden after the tar end count against the cap.
let mut inner = ar.into_inner();
let result = result.and_then(|()| io::copy(&mut inner, &mut io::sink()).map(drop));
if let Err(e) = result {
if inner.hit {
w.over(format!(
"the archive unpacks past {} bytes",
limits.max_unpacked_bytes
));
} else if count > limits.max_entries {
w.over(e.to_string());
} else {
w.unreadable = Some(format!("the gzip'd tar is corrupt: {e}"));
}
}
}
/// A hard link names another member by its full path from the root.
fn escapes_hard(target: &[u8]) -> bool {
std::str::from_utf8(target).map_or(true, escapes)
}