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