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/tests/adversarial.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Hostile archives: bombs, traversal names, links out of the root, bad
//! UTF-8, control characters, deep nesting, huge manifests, too many members,
//! corrupt and truncated streams, zip64 and overlapping zips. Each must end
//! in a finding or in unknown, never in clean and never in a panic. Heimdall
//! is a defensive sandbox; these archives exercise its own parser only.

mod common;
use common::*;

use heimdall_guest_static_scan::archive::ids;
use heimdall_scan::report::unknown;
use heimdall_scan::{Check, CheckResult, Severity, Verdict};

fn behavior_with(artifact: &[u8], purl: &str, extra: &[(&str, &str)]) -> CheckResult {
    let mut p = vec![("purl", purl), ("checks", "behavior")];
    p.extend_from_slice(extra);
    check(&envelope(artifact, &[]), &p, Check::Behavior)
}

fn behavior(artifact: &[u8]) -> CheckResult {
    behavior_with(artifact, "pkg:npm/x@1.0.0", &[])
}

fn with_manifest(mut members: Vec<T<'static>>) -> Vec<u8> {
    members.insert(0, T::File(b"package/package.json", NPM_BENIGN));
    tgz(&members)
}

fn finding_of<'a>(r: &'a CheckResult, id: &str) -> &'a heimdall_scan::Finding {
    r.findings
        .iter()
        .find(|f| f.id == id)
        .unwrap_or_else(|| panic!("no {id} in {:?}", ids(r)))
}

#[test]
fn a_gzip_bomb_past_the_unpack_cap_is_unknown_over_limits() {
    // 80 MiB of zeros in one member: about 80 KiB compressed.
    let zeros = vec![0u8; 80 << 20];
    let art = tgz(&[
        T::File(b"package/package.json", NPM_BENIGN),
        T::File(b"package/zeros", &zeros),
    ]);
    assert!(art.len() < 1 << 20);
    let r = behavior(&art);
    assert_unknown(&r, unknown::ARCHIVE_OVER_LIMITS);
}

#[test]
fn the_unpack_cap_is_a_job_parameter() {
    let art = tgz(&[
        T::File(b"package/package.json", NPM_BENIGN),
        T::File(b"package/blob", &[7u8; 8192]),
    ]);
    assert_clean(&behavior(&art));
    assert_unknown(
        &behavior_with(&art, "pkg:npm/x@1.0.0", &[("max-unpacked-bytes", "4096")]),
        unknown::ARCHIVE_OVER_LIMITS,
    );
}

#[test]
fn a_stream_that_ends_exactly_at_the_cap_is_not_over_it() {
    let tar = tar_bytes(&[T::File(b"package/package.json", NPM_BENIGN)]);
    let cap = tar.len().to_string();
    assert_clean(&behavior_with(
        &gz(&tar),
        "pkg:npm/x@1.0.0",
        &[("max-unpacked-bytes", &cap)],
    ));
}

#[test]
fn a_dot_dot_member_name_is_a_high_path_traversal_finding() {
    let r = behavior(&with_manifest(vec![T::File(
        b"package/../../etc/cron.d/x",
        b"* * * * * root sh",
    )]));
    let f = finding_of(&r, ids::PATH_TRAVERSAL);
    assert_eq!(f.severity, Severity::High);
    assert_eq!(f.path.as_deref(), Some("package/../../etc/cron.d/x"));
}

#[test]
fn an_absolute_member_name_is_path_traversal() {
    let r = behavior(&with_manifest(vec![T::File(b"/etc/passwd", b"root::0:0")]));
    assert!(has(&r, ids::PATH_TRAVERSAL));
}

#[test]
fn a_backslash_or_drive_member_name_is_path_traversal() {
    for name in [&b"package\\..\\..\\x"[..], b"C:/Windows/x"] {
        let r = behavior(&with_manifest(vec![T::File(name, b"x")]));
        assert!(
            has(&r, ids::PATH_TRAVERSAL),
            "{:?}",
            String::from_utf8_lossy(name)
        );
    }
}

#[test]
fn a_traversal_name_is_never_read_as_the_manifest() {
    let art = tgz(&[T::File(b"../package.json", NPM_DROPPER)]);
    let r = behavior(&art);
    assert!(has(&r, ids::PATH_TRAVERSAL));
    assert!(!has(&r, "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"));
    assert_eq!(unknown_code(&r), Some(unknown::NO_MANIFEST));
}

#[test]
fn a_symlink_out_of_the_root_is_a_high_link_finding() {
    let r = behavior(&with_manifest(vec![T::Symlink(
        b"package/x",
        b"../../../etc/shadow",
    )]));
    assert_eq!(finding_of(&r, ids::LINK_ESCAPE).severity, Severity::High);
}

#[test]
fn an_absolute_symlink_is_a_link_finding_and_an_inner_one_is_not() {
    let r = behavior(&with_manifest(vec![T::Symlink(
        b"package/a",
        b"/etc/passwd",
    )]));
    assert!(has(&r, ids::LINK_ESCAPE));
    let r = behavior(&with_manifest(vec![T::Symlink(
        b"package/lib/a",
        b"../index.js",
    )]));
    assert_clean(&r);
}

#[test]
fn a_hard_link_to_an_outside_path_is_a_link_finding() {
    let r = behavior(&with_manifest(vec![T::HardLink(
        b"package/x",
        b"/etc/shadow",
    )]));
    assert!(has(&r, ids::LINK_ESCAPE));
}

#[test]
fn a_fifo_member_is_a_special_file_finding() {
    let r = behavior(&with_manifest(vec![T::Fifo(b"package/pipe")]));
    assert!(has(&r, ids::SPECIAL_FILE));
}

#[test]
fn a_member_name_that_is_not_utf8_is_a_finding() {
    let r = behavior(&with_manifest(vec![T::File(b"package/\xff\xfe.js", b"x")]));
    let f = finding_of(&r, ids::NAME_NOT_UTF8);
    assert!(f.path.as_deref().unwrap().contains('\u{fffd}'));
}

#[test]
fn a_member_name_with_control_characters_is_a_finding_and_shown_escaped() {
    let r = behavior(&with_manifest(vec![T::File(b"package/a\x1b[2Jb", b"x")]));
    let f = finding_of(&r, ids::NAME_CONTROL);
    assert!(!f.path.as_deref().unwrap().chars().any(char::is_control));
}

#[test]
fn a_member_nested_past_the_depth_limit_is_a_finding() {
    let deep = format!("package/{}x", "d/".repeat(40));
    let deep: &'static [u8] = Box::leak(deep.into_bytes().into_boxed_slice());
    let r = behavior(&with_manifest(vec![T::File(deep, b"x")]));
    assert!(has(&r, ids::DEEP_NESTING));
    // The shown path is cut at 256 characters even for a long name.
    let long = format!("package/{}", "a".repeat(400));
    let long: &'static [u8] = Box::leak(long.into_bytes().into_boxed_slice());
    let r = behavior_with(
        &with_manifest(vec![T::File(long, b"x")]),
        "pkg:npm/x@1.0.0",
        &[("max-depth", "1")],
    );
    let f = r
        .findings
        .iter()
        .find(|f| {
            f.path
                .as_deref()
                .is_some_and(|p| p.starts_with("package/aaaa"))
        })
        .expect("the long member's finding");
    assert_eq!(f.id, ids::DEEP_NESTING);
    let shown = f.path.as_deref().unwrap();
    assert_eq!(shown.chars().count(), 257);
    assert!(shown.ends_with('…'));
}

#[test]
fn a_manifest_over_the_manifest_cap_is_unknown_over_limits() {
    let mut big = br#"{"name":"x","description":""#.to_vec();
    big.extend(std::iter::repeat_n(b'a', 2 << 20));
    big.extend_from_slice(br#""}"#);
    let art = npm_tgz(&big);
    assert_unknown(&behavior(&art), unknown::ARCHIVE_OVER_LIMITS);
}

#[test]
fn more_members_than_max_entries_is_unknown_over_limits() {
    let names: Vec<String> = (0..20).map(|i| format!("package/f{i}")).collect();
    let mut m = vec![T::File(b"package/package.json", NPM_BENIGN)];
    for n in &names {
        m.push(T::File(n.as_bytes(), b"x"));
    }
    let art = tgz(&m);
    assert_clean(&behavior(&art));
    assert_unknown(
        &behavior_with(&art, "pkg:npm/x@1.0.0", &[("max-entries", "10")]),
        unknown::ARCHIVE_OVER_LIMITS,
    );
}

#[test]
fn a_dropper_found_before_a_cap_fires_is_still_reported() {
    let zeros = vec![0u8; 80 << 20];
    let art = tgz(&[
        T::File(b"package/package.json", NPM_DROPPER),
        T::File(b"package/z", &zeros),
    ]);
    let r = behavior(&art);
    assert_eq!(r.verdict, Verdict::Findings);
    assert_eq!(unknown_code(&r), Some(unknown::ARCHIVE_OVER_LIMITS));
}

#[test]
fn a_truncated_gzip_stream_is_unknown_unreadable() {
    let art = npm_tgz(NPM_BENIGN);
    let cut = &art[..art.len() / 2];
    assert_unknown(&behavior(cut), unknown::ARCHIVE_UNREADABLE);
}

#[test]
fn bytes_that_are_neither_tar_gz_nor_zip_are_unknown_unreadable() {
    for art in [&b""[..], b"hello", NPM_DROPPER, &[0u8; 1024]] {
        assert_unknown(&behavior(art), unknown::ARCHIVE_UNREADABLE);
    }
}

#[test]
fn gzip_of_garbage_is_unknown_unreadable() {
    let art = gz(&[0x42u8; 4096]);
    assert_unknown(&behavior(&art), unknown::ARCHIVE_UNREADABLE);
}

#[test]
fn a_tar_member_claiming_more_bytes_than_the_stream_holds_is_unreadable() {
    let mut tar = tar_bytes(&[T::File(b"package/package.json", NPM_BENIGN)]);
    // Size field: 11 octal digits at 124.
    tar[124..135].copy_from_slice(b"77777777777");
    // Fix the checksum so only the size lies.
    let mut h = tar::Header::new_old();
    h.as_mut_bytes().copy_from_slice(&tar[..512]);
    h.set_cksum();
    tar[..512].copy_from_slice(h.as_bytes());
    assert_unknown(&behavior(&gz(&tar)), unknown::ARCHIVE_UNREADABLE);
}

#[test]
fn a_zip_whose_members_overlap_is_a_bomb_finding_and_unknown() {
    // Two central entries pointing at the same local member.
    let one = zip(&[Z::file(b"package/package.json", NPM_BENIGN)]);
    let cd_off = u32::from_le_bytes(one[one.len() - 6..one.len() - 2].try_into().unwrap()) as usize;
    let local = one[..cd_off].to_vec();
    let mut central = one[cd_off..one.len() - 22].to_vec();
    let mut second = central.clone();
    // Rename the second entry so the duplicate rule is not what fires.
    let at = second.len() - "package/package.json".len();
    second[at..].copy_from_slice(b"package/package.jsoN");
    central.extend_from_slice(&second);
    let art = finish_zip(local, central, 2);
    let r = behavior_with(&art, "pkg:pypi/x@1.0", &[]);
    assert!(has(&r, ids::ZIP_OVERLAP), "{r:?}");
    assert_eq!(unknown_code(&r), Some(unknown::ARCHIVE_OVER_LIMITS));
}

#[test]
fn a_zip_declaring_more_unpacked_bytes_than_the_cap_is_unknown() {
    let zeros = vec![0u8; 70 << 20];
    let art = wheel(&[Z::file(b"okpkg/zeros.bin", &zeros)]);
    assert!(art.len() < 1 << 20);
    assert_unknown(
        &behavior_with(&art, "pkg:pypi/okpkg@1.0", &[]),
        unknown::ARCHIVE_OVER_LIMITS,
    );
}

#[test]
fn a_zip_manifest_that_inflates_to_more_than_it_declares_is_unknown() {
    let mut art = zip(&[Z::file(b"okpkg-1.0/setup.py", SETUP_DROPPER)]);
    // Lower the declared size in the central entry (offset 24 of it).
    let cd_off = u32::from_le_bytes(art[art.len() - 6..art.len() - 2].try_into().unwrap()) as usize;
    art[cd_off + 24..cd_off + 28].copy_from_slice(&10u32.to_le_bytes());
    let r = behavior_with(&art, "pkg:pypi/okpkg@1.0", &[]);
    assert_ne!(r.verdict, Verdict::Clean, "{r:?}");
    assert_eq!(unknown_code(&r), Some(unknown::ARCHIVE_OVER_LIMITS));
}

#[test]
fn a_zip64_marker_is_unknown_unreadable() {
    let mut art = wheel(&[]);
    let n = art.len();
    art[n - 6..n - 2].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
    assert_unknown(
        &behavior_with(&art, "pkg:pypi/okpkg@1.0", &[]),
        unknown::ARCHIVE_UNREADABLE,
    );
}

#[test]
fn a_zip_traversal_name_and_an_escaping_zip_symlink_are_findings() {
    let art = wheel(&[
        Z::file(b"../../../usr/lib/python3/sitecustomize.py", b"import os"),
        Z {
            name: b"okpkg/link",
            data: b"../../../../etc/passwd",
            deflate: false,
            mode: 0o120777,
        },
    ]);
    let r = behavior_with(&art, "pkg:pypi/okpkg@1.0", &[]);
    assert!(has(&r, ids::PATH_TRAVERSAL), "{:?}", ids(&r));
    assert!(has(&r, ids::LINK_ESCAPE), "{:?}", ids(&r));
}

#[test]
fn a_zip_with_its_central_directory_cut_off_is_unknown_unreadable() {
    let art = wheel(&[]);
    assert_unknown(
        &behavior_with(&art[..art.len() - 30], "pkg:pypi/okpkg@1.0", &[]),
        unknown::ARCHIVE_UNREADABLE,
    );
}

#[test]
fn a_zip_member_pointing_past_the_data_area_is_unknown_unreadable() {
    let mut art = wheel(&[]);
    let cd_off = u32::from_le_bytes(art[art.len() - 6..art.len() - 2].try_into().unwrap()) as usize;
    // Compressed size of the first central entry: huge.
    art[cd_off + 20..cd_off + 24].copy_from_slice(&0x7fff_0000u32.to_le_bytes());
    assert_unknown(
        &behavior_with(&art, "pkg:pypi/okpkg@1.0", &[]),
        unknown::ARCHIVE_UNREADABLE,
    );
}

#[test]
fn many_tiny_members_within_the_limits_are_walked() {
    let names: Vec<String> = (0..5000).map(|i| format!("package/lib/m{i}.js")).collect();
    let mut m = vec![T::File(b"package/package.json", NPM_DROPPER)];
    for n in &names {
        m.push(T::File(n.as_bytes(), b"1"));
    }
    let r = behavior(&tgz(&m));
    assert!(has(&r, "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"));
    assert!(r.unknown.is_none());
}