Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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)
}