#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) SCANNER=$ROOT/scripts/check-source-only.sh TEST_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-source-only-tests.XXXXXXXX") PASSED=0 cleanup() { local status=$? trap - EXIT HUP INT TERM rm -rf -- "$TEST_TMP" exit "$status" } trap cleanup EXIT HUP INT TERM pass() { PASSED=$((PASSED + 1)) printf 'ok %d - %s\n' "$PASSED" "$1" } skip() { PASSED=$((PASSED + 1)) printf 'ok %d - %s # SKIP\n' "$PASSED" "$1" } expect_pass_tree() { local name=$1 tree=$2 output=$TEST_TMP/output if "$SCANNER" --tree "$tree" >"$output" 2>&1 && grep -q '^PASS source-only:' "$output"; then pass "$name" else printf 'not ok - %s\n' "$name" sed -n '1,120p' "$output" exit 1 fi } expect_fail_tree() { local name=$1 tree=$2 expected=${3:-} output=$TEST_TMP/output if "$SCANNER" --tree "$tree" >"$output" 2>&1; then printf 'not ok - %s (scanner unexpectedly passed)\n' "$name" exit 1 fi grep -q '^FAIL ' "$output" || { printf 'not ok - %s (missing FAIL finding)\n' "$name" exit 1 } grep -q '^FAIL source-only:' "$output" || { printf 'not ok - %s (missing FAIL summary)\n' "$name" exit 1 } if [[ -n $expected ]] && ! grep -Fq -- "$expected" "$output"; then printf 'not ok - %s (missing expected path)\n' "$name" exit 1 fi pass "$name" } expect_fail_archive_with_limits() { local name=$1 archive=$2 expected=$3 shift 3 local output=$TEST_TMP/output if env "$@" "$SCANNER" --archive "$archive" >"$output" 2>&1; then printf 'not ok - %s (scanner unexpectedly passed)\n' "$name" exit 1 fi if ! grep -Fq -- "$expected" "$output"; then printf 'not ok - %s (missing expected bounded-archive finding)\n' "$name" sed -n '1,120p' "$output" exit 1 fi pass "$name" } fresh_tree() { local name=$1 mkdir -p "$TEST_TMP/$name" printf '%s' "$TEST_TMP/$name" } safe=$(fresh_tree safe) mkdir -p "$safe/src" "$safe/assets" printf '#include \nint main(void) { return 0; }\n' >"$safe/src/main.c" printf '.text\n.globl portable_symbol\nportable_symbol:\n ret\n' >"$safe/src/portable.S" printf '\211PNG\r\n\032\n' >"$safe/assets/icon.png" printf '\000\000\001\000' >"$safe/assets/icon.ico" if ln -s src/main.c "$safe/main-link.c" 2>/dev/null && [[ -L $safe/main-link.c ]]; then SYMLINKS_SUPPORTED=1 safe_label='text source, assembly, PNG, ICO, and internal symlink pass' else SYMLINKS_SUPPORTED=0 safe_label='text source, assembly, PNG, and ICO pass (symlink unavailable)' fi expect_pass_tree "$safe_label" "$safe" tree=$(fresh_tree undeclared-bin) printf '\001\002\003fixture data\n' >"$tree/vector.bin" expect_fail_tree 'undeclared .bin data is rejected' "$tree" vector.bin tree=$(fresh_tree declared-bin) mkdir -p "$tree/tests/data" printf '\001\002\003fixture data\n' >"$tree/tests/data/vector.bin" manifest=$TEST_TMP/source-data.tsv printf 'tests/data/vector.bin\ttest vector\tgenerated by test_source_only.sh\tAGPL-3.0-or-later\n' >"$manifest" if "$SCANNER" --data-manifest "$manifest" --tree "$tree" >"$TEST_TMP/output" 2>&1 && grep -q '^PASS source-only:' "$TEST_TMP/output"; then pass 'declared non-executable .bin fixture passes with complete metadata' else printf 'not ok - declared non-executable .bin fixture passes\n' sed -n '1,120p' "$TEST_TMP/output" exit 1 fi printf '\177ELF\002\001\001\000compiled' >"$tree/tests/data/vector.bin" if "$SCANNER" --data-manifest "$manifest" --tree "$tree" >"$TEST_TMP/output" 2>&1; then printf 'not ok - manifest cannot allow executable magic\n' exit 1 elif grep -Fq 'tests/data/vector.bin' "$TEST_TMP/output"; then pass 'data manifest cannot exempt executable magic' else printf 'not ok - executable magic path missing from manifest test\n' exit 1 fi tree=$(fresh_tree elf) printf '\177ELF\002\001\001\000compiled' >"$tree/renamed.txt" expect_fail_tree 'ELF renamed as text is rejected' "$tree" renamed.txt case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) skip 'control-byte filenames are forbidden by the Windows filesystem' skip 'raw C1 filenames are forbidden by the Windows filesystem' skip 'UTF-8 C1 filenames are forbidden by the Windows filesystem' skip 'bidirectional-control filenames are forbidden by the Windows filesystem' skip 'printable UTF-8 filename preservation is not exercised on Windows' ;; *) tree=$(fresh_tree control-path) control_name=$'escape\033[31m.txt' printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then printf 'not ok - control-byte path was not rejected\n' exit 1 elif grep -q $'\033' "$TEST_TMP/output" || ! grep -Fq 'escape\x1b[31m.txt' "$TEST_TMP/output"; then printf 'not ok - control-byte path was not rendered safely\n' exit 1 else pass 'scanner escapes terminal control bytes in reported paths' fi tree=$(fresh_tree raw-c1-path) control_name=$'raw-\200.txt' printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then printf 'not ok - raw C1 path was not rejected\n' exit 1 elif ! grep -Fq 'raw-\x80.txt' "$TEST_TMP/output" || LC_ALL=C grep -q $'\200' "$TEST_TMP/output"; then printf 'not ok - raw C1 path was not rendered safely\n' exit 1 else pass 'scanner escapes invalid raw C1 bytes in reported paths' fi tree=$(fresh_tree utf8-c1-path) control_name=$'utf8-\302\233.txt' printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then printf 'not ok - UTF-8 C1 path was not rejected\n' exit 1 elif ! grep -Fq 'utf8-\u009b.txt' "$TEST_TMP/output" || LC_ALL=C grep -q $'\302\233' "$TEST_TMP/output"; then printf 'not ok - UTF-8 C1 path was not rendered safely\n' exit 1 else pass 'scanner escapes UTF-8-encoded C1 controls in reported paths' fi tree=$(fresh_tree bidi-path) control_name=$'report-\342\200\256txt.exe' printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then printf 'not ok - bidirectional-control path was not rejected\n' exit 1 elif ! grep -Fq 'report-\u202etxt.exe' "$TEST_TMP/output" || LC_ALL=C grep -q $'\342\200\256' "$TEST_TMP/output"; then printf 'not ok - bidirectional-control path was not rendered safely\n' exit 1 else pass 'scanner escapes UTF-8 bidirectional controls in reported paths' fi tree=$(fresh_tree printable-utf8-path) control_name=$'caf\303\251.txt' printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then printf 'not ok - printable UTF-8 path was not rejected\n' exit 1 elif ! LC_ALL=C grep -Fq -- "$control_name" "$TEST_TMP/output"; then printf 'not ok - printable UTF-8 path was not preserved\n' exit 1 else pass 'scanner preserves printable UTF-8 in reported paths' fi ;; esac tree=$(fresh_tree ar) printf '!\n' >"$tree/renamed.data" expect_fail_tree 'ar library renamed as data is rejected' "$tree" renamed.data tree=$(fresh_tree thin-ar) printf '!\n' >"$tree/renamed.data" expect_fail_tree 'GNU thin archive renamed as data is rejected' "$tree" renamed.data tree=$(fresh_tree mz) printf 'MZnot-source' >"$tree/renamed.data" expect_fail_tree 'PE/MZ renamed as data is rejected' "$tree" renamed.data tree=$(fresh_tree macho) printf '\376\355\372\317compiled' >"$tree/renamed.data" expect_fail_tree 'Mach-O renamed as data is rejected' "$tree" renamed.data tree=$(fresh_tree coff) printf '\144\206\001\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' >"$tree/renamed.data" expect_fail_tree 'COFF object renamed as data is rejected' "$tree" renamed.data tree=$(fresh_tree lfs) printf 'version https://git-lfs.github.com/spec/v1\noid sha256:0000\nsize 4\n' >"$tree/pointer.c" expect_fail_tree 'unresolved Git LFS pointer is rejected' "$tree" pointer.c tree=$(fresh_tree symlink) if ((SYMLINKS_SUPPORTED)) && ln -s ../../outside "$tree/escape" 2>/dev/null && [[ -L $tree/escape ]]; then expect_fail_tree 'escaping symlink is rejected' "$tree" escape else skip 'escaping symlink test is unsupported by this runner' fi tree=$(fresh_tree so-version) printf 'not actually compiled\n' >"$tree/libexample.so.1" expect_fail_tree 'versioned shared-library extension is rejected' "$tree" libexample.so.1 tree=$(fresh_tree rpm) printf '\355\253\356\333package' >"$tree/renamed.data" expect_fail_tree 'RPM magic is rejected without relying on extension' "$tree" renamed.data tree=$(fresh_tree deb) printf '!\ndebian-binary 000000000000000000000000000000000000000000000000000000\n' >"$tree/renamed.data" expect_fail_tree 'DEB magic is rejected without relying on extension' "$tree" renamed.data tree=$(fresh_tree appimage) printf '\177ELF\002\001\001\000AI\002payload' >"$tree/renamed.data" expect_fail_tree 'AppImage magic is rejected without relying on extension' "$tree" renamed.data tree=$(fresh_tree wasm) printf '\000asm\001\000\000\000' >"$tree/module.data" expect_fail_tree 'WebAssembly magic is rejected' "$tree" module.data tree=$(fresh_tree class) printf '\312\376\272\276\000\000\000\075' >"$tree/class.data" expect_fail_tree 'Java class magic is rejected' "$tree" class.data tree=$(fresh_tree pyc) printf '\247\015\015\012\000\000\000\000\000\000\000\000\000\000\000\000' >"$tree/python.data" expect_fail_tree 'Python bytecode magic is rejected' "$tree" python.data tree=$(fresh_tree nested) mkdir -p "$tree/input" printf '\177ELF\002\001\001\000nested' >"$tree/input/payload.txt" tar -C "$tree/input" -cf "$tree/outer.tar" payload.txt rm -rf -- "$tree/input" expect_fail_tree 'compiled content inside an archive is rejected' "$tree" 'outer.tar!payload.txt' tree=$(fresh_tree renamed-7z) printf '\067\172\274\257\047\034malformed' >"$tree/renamed.data" expect_fail_tree '7z magic is recognized and cannot bypass archive inspection' \ "$tree" renamed.data tree=$(fresh_tree renamed-rar) printf 'Rar!\032\007\001\000malformed' >"$tree/renamed.data" expect_fail_tree 'RAR magic is recognized and cannot bypass archive inspection' \ "$tree" renamed.data tree=$(fresh_tree empty-archive) tar -cf "$tree/empty.tar" --files-from /dev/null expect_fail_tree 'empty archives are rejected as having no inspectable source' \ "$tree" empty.tar tree=$(fresh_tree member-limit) mkdir -p "$tree/input" for member_number in 1 2 3 4; do printf 'source %s\n' "$member_number" >"$tree/input/$member_number.c" done tar -C "$tree/input" -cf "$tree/members.tar" . expect_fail_archive_with_limits \ 'archive member count is bounded during preflight listing' \ "$tree/members.tar" 'archive member limit exceeded' \ SOURCE_AUDIT_MAX_MEMBERS=3 expect_fail_archive_with_limits \ 'archive member-name output is byte-bounded during preflight listing' \ "$tree/members.tar" 'archive member-name budget exceeded' \ SOURCE_AUDIT_MAX_LIST_KIB=0 tree=$(fresh_tree expanded-limit) mkdir -p "$tree/input" dd if=/dev/zero of="$tree/input/zeros.c" bs=1024 count=2048 2>/dev/null tar -C "$tree/input" -czf "$tree/compressed-size-bomb.tar.gz" zeros.c expect_fail_archive_with_limits \ 'compressed archive declared size is rejected before extraction' \ "$tree/compressed-size-bomb.tar.gz" \ 'archive declared-size limit exceeded before extraction' \ SOURCE_AUDIT_MAX_KIB=1024 tree=$(fresh_tree global-expanded-limit) mkdir -p "$tree/one" "$tree/two" dd if=/dev/zero of="$tree/one/one.c" bs=700 count=1 2>/dev/null dd if=/dev/zero of="$tree/two/two.c" bs=700 count=1 2>/dev/null tar -C "$tree/one" -cf "$tree/one.tar" one.c tar -C "$tree/two" -cf "$tree/two.tar" two.c if env SOURCE_AUDIT_MAX_KIB=2 SOURCE_AUDIT_MAX_TOTAL_KIB=1 \ "$SCANNER" --archive "$tree/one.tar" --archive "$tree/two.tar" \ >"$TEST_TMP/output" 2>&1; then printf 'not ok - global archive size budget unexpectedly passed\n' exit 1 elif grep -Fq 'global archive declared-size budget exceeded' "$TEST_TMP/output"; then pass 'global declared-size budget covers multiple archives' else printf 'not ok - global archive size budget finding missing\n' sed -n '1,120p' "$TEST_TMP/output" exit 1 fi tree=$(fresh_tree archive-symlink) mkdir -p "$tree/input" if ((SYMLINKS_SUPPORTED)) && ln -s ../../outside "$tree/input/escape" 2>/dev/null && [[ -L $tree/input/escape ]]; then tar -C "$tree/input" -cf "$tree/escape.tar" escape rm -rf -- "$tree/input" expect_fail_tree 'escaping symlink inside an archive is rejected before extraction' "$tree" 'escape.tar!escape' else skip 'archive symlink test is unsupported by this runner' fi tree=$(fresh_tree bad-ref) printf 'SDK_LIB = vendor/vuptsdk/libvuptsdk.so.2\n' >"$tree/Makefile" expect_fail_tree 'removed vendored library references are rejected' "$tree" Makefile archive_src=$(fresh_tree standalone-archive) printf 'source text\n' >"$archive_src/source.c" tar -C "$archive_src" -cf "$TEST_TMP/source.tar" source.c if "$SCANNER" --archive "$TEST_TMP/source.tar" >"$TEST_TMP/output" 2>&1 && grep -q '^PASS source-only:' "$TEST_TMP/output"; then pass 'standalone source archive passes' else printf 'not ok - standalone source archive passes\n' sed -n '1,120p' "$TEST_TMP/output" exit 1 fi if SOURCE_AUDIT_FORCE_WATCHDOG=1 \ "$SCANNER" --archive "$TEST_TMP/source.tar" >"$TEST_TMP/output" 2>&1 && grep -q '^PASS source-only:' "$TEST_TMP/output"; then pass 'portable archive watchdog fallback completes a normal scan' else printf 'not ok - portable archive watchdog fallback\n' sed -n '1,120p' "$TEST_TMP/output" exit 1 fi repo=$TEST_TMP/repository mkdir -p "$repo" git -C "$repo" init -q git -C "$repo" config user.name 'Source Audit Test' git -C "$repo" config user.email 'source-audit@example.invalid' printf 'safe source\n' >"$repo/source.c" printf '*.o\n' >"$repo/.gitignore" mkdir -p "$repo/tests" "$repo/scripts" "$repo/packaging/opensuse" printf 'fixture mentions vendor/vuptsdk/libvuptsdk.so.2\n' >"$repo/tests/test_source_only.sh" printf '# scanner implementation fixture\n' >"$repo/scripts/check-source-only.sh" printf '# scanner wrapper fixture\n' >"$repo/packaging/opensuse/source-audit.sh" git -C "$repo" add source.c .gitignore tests scripts packaging git -C "$repo" commit -qm 'safe source' git -C "$repo" tag v1.0.0 if "$SCANNER" --root "$repo" --tag v1.0.0 >"$TEST_TMP/output" 2>&1 && grep -q '^PASS source-only:' "$TEST_TMP/output"; then pass 'tracked, working-tree, HEAD archive, and tag archive pass' else printf 'not ok - repository and tag audit pass\n' sed -n '1,120p' "$TEST_TMP/output" exit 1 fi printf '\177ELF\002\001\001\000ignored' >"$repo/ignored.o" if "$SCANNER" --root "$repo" >"$TEST_TMP/output" 2>&1; then printf 'not ok - ignored working-tree object is rejected\n' exit 1 elif grep -Fq ignored.o "$TEST_TMP/output"; then pass 'ignored working-tree object is rejected' else printf 'not ok - ignored object path missing\n' exit 1 fi rm -f -- "$repo/ignored.o" printf '\177ELF\002\001\001\000indexed' >"$repo/indexed.txt" git -C "$repo" add indexed.txt printf 'safe worktree replacement\n' >"$repo/indexed.txt" if "$SCANNER" --root "$repo" >"$TEST_TMP/output" 2>&1; then printf 'not ok - compiled indexed blob is rejected\n' exit 1 elif grep -Fq indexed.txt "$TEST_TMP/output"; then pass 'Git index content is audited independently of the worktree' else printf 'not ok - indexed path missing\n' exit 1 fi printf '1..%d\n' "$PASSED"