FDS/OS 1.0
This commit is contained in:
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate the exported tar, then corrupt disposable copies to test rejection.
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
[[ -f out/rootfs-aarch64.tar ]] || die 'Run make rootfs PROFILE=cli first'
|
||||
scratch=$(mktemp -d "$FDS_ROOT/out/m1-checks.XXXXXX")
|
||||
trap 'rm -rf -- "$scratch"' EXIT
|
||||
build=$(dirname -- "$(realpath out/rootfs-aarch64.tar)")
|
||||
(
|
||||
cd "$build"
|
||||
sha256sum -c rootfs.sha256 packages.sha256
|
||||
)
|
||||
python3 - "$build" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
import tarfile
|
||||
build = pathlib.Path(sys.argv[1])
|
||||
metadata = json.loads((build / 'archive-metadata.json').read_text())
|
||||
with tarfile.open(build / 'rootfs-aarch64.tar') as archive:
|
||||
members = {member.name: member for member in archive}
|
||||
assert members['.'].isdir() and (members['.'].uid, members['.'].gid, members['.'].mode) == (0, 0, 0o755), 'Incorrect root directory metadata'
|
||||
assert members.keys() == metadata.keys(), 'Archive member mismatch'
|
||||
for name, member in members.items():
|
||||
assert not name.startswith('/') and '..' not in pathlib.PurePosixPath(name).parts
|
||||
assert not (member.ischr() or member.isblk()), name
|
||||
if member.isfifo():
|
||||
assert name.startswith('etc/s6-linux-init/current/run-image/'), name
|
||||
expected = metadata[name]
|
||||
assert (member.uid, member.gid, member.mode) == (expected['uid'], expected['gid'], int(expected['mode'], 8)), name
|
||||
for name in ('usr/bin/wall', 'usr/bin/write'):
|
||||
if name in members:
|
||||
assert (members[name].uid, members[name].gid, members[name].mode) == (0, 5, 0o2755), name
|
||||
assert (members['usr/lib/utempter/utempter'].uid, members['usr/lib/utempter/utempter'].gid, members['usr/lib/utempter/utempter'].mode) == (0, 14, 0o2711), 'Incorrect utempter group restoration'
|
||||
assert members['etc/shadow'].mode == 0o600
|
||||
assert members['tmp'].mode == 0o1777
|
||||
assert members['root'].mode == 0o700
|
||||
assert (members['usr/bin/xbps-uchroot'].uid, members['usr/bin/xbps-uchroot'].gid, members['usr/bin/xbps-uchroot'].mode) == (0, 101, 0o4750)
|
||||
assert members['bin'].linkname == 'usr/bin'
|
||||
capability = members['usr/bin/iputils-ping'].pax_headers['SCHILY.xattr.security.capability'].encode('utf-8', 'surrogateescape')
|
||||
assert len(capability) == 20 and struct.unpack_from('<I', capability)[0] >> 24 == 2
|
||||
assert struct.unpack_from('<I', capability, 4)[0] & (1 << 13), 'Missing CAP_NET_RAW'
|
||||
print('PASS: archive paths, ownership, modes and portable ping capability')
|
||||
PY
|
||||
mkdir "$scratch/capability-restore"
|
||||
# Exercise GNU tar's actual xattr restoration inside a private user namespace.
|
||||
# The resulting v3 xattr is namespaced to this host user, not host root.
|
||||
bwrap --unshare-user --uid 0 --gid 0 --cap-add CAP_SETFCAP \
|
||||
--ro-bind / / --bind "$scratch/capability-restore" "$scratch/capability-restore" \
|
||||
--chdir "$scratch/capability-restore" \
|
||||
tar --extract --file "$FDS_ROOT/out/rootfs-aarch64.tar" --numeric-owner \
|
||||
--same-permissions --xattrs --xattrs-include=security.capability usr/bin/iputils-ping
|
||||
python3 - "$scratch/capability-restore/usr/bin/iputils-ping" <<'PY'
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
cap = os.getxattr(sys.argv[1], 'security.capability')
|
||||
assert len(cap) == 24 and struct.unpack_from('<I', cap)[0] >> 24 == 3
|
||||
assert struct.unpack_from('<I', cap, 20)[0] == os.getuid()
|
||||
assert struct.unpack_from('<I', cap, 4)[0] & (1 << 13)
|
||||
print('PASS: GNU tar restores ping capability in a private user namespace')
|
||||
PY
|
||||
mkdir "$scratch/root"
|
||||
# Extract as the current user, preserving target modes for the audit. Capabilities
|
||||
# are verified above; this test does not grant capabilities to host-owned files.
|
||||
tar --extract --file out/rootfs-aarch64.tar --directory "$scratch/root" \
|
||||
--no-same-owner --same-permissions
|
||||
python3 tools/rootfs-audit "$scratch/root"
|
||||
tools/rootfs-runtime-check "$scratch/root"
|
||||
python3 - "$scratch/root" <<'PY'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import plistlib
|
||||
import shutil
|
||||
import sys
|
||||
sys.path.insert(0, 'tools')
|
||||
loader = importlib.machinery.SourceFileLoader('audit_module', 'tools/rootfs-audit')
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
database = root / 'var/db/xbps/pkgdb-0.38.plist'
|
||||
original = database.read_bytes()
|
||||
db = plistlib.loads(original)
|
||||
|
||||
def reject(label, needle):
|
||||
try:
|
||||
module.audit(root)
|
||||
except AssertionError as error:
|
||||
assert needle in str(error), (label, error)
|
||||
print('PASS: rejects ' + label)
|
||||
else:
|
||||
raise AssertionError('Incorrectly accepted: ' + label)
|
||||
|
||||
db['busybox'] = {'architecture': 'aarch64', 'state': 'installed'}
|
||||
database.write_bytes(plistlib.dumps(db))
|
||||
reject('forbidden package', 'Forbidden package')
|
||||
database.write_bytes(original)
|
||||
db = plistlib.loads(original)
|
||||
db['glibc']['state'] = 'unpacked'
|
||||
database.write_bytes(plistlib.dumps(db))
|
||||
reject('unfinished package configuration', 'Unconfigured package')
|
||||
database.write_bytes(original)
|
||||
db = plistlib.loads(original)
|
||||
db['glibc']['architecture'] = 'aarch64-musl'
|
||||
database.write_bytes(plistlib.dumps(db))
|
||||
reject('wrong package ABI', 'Wrong package ABI')
|
||||
database.write_bytes(original)
|
||||
binary = root / 'usr/bin/ls'
|
||||
backup = binary.read_bytes()
|
||||
binary.write_bytes(pathlib.Path('/usr/bin/ls').read_bytes())
|
||||
reject('host ELF contamination', 'Non-aarch64 ELF')
|
||||
binary.write_bytes(backup)
|
||||
marker = root / 'etc/s6-rc/source/boot/contents.d/dasungd'
|
||||
marker.unlink()
|
||||
reject('missing base Dasung service', 'Dasung missing')
|
||||
marker.touch()
|
||||
init = root / 'etc/s6-linux-init/current/bin/init'
|
||||
original_init = init.read_bytes()
|
||||
init.write_text('#!/bin/bash\nexec /usr/bin/s6-svscan /run/service\n')
|
||||
reject('shell PID 1 launcher', 'Init is not the native s6 launcher')
|
||||
init.write_bytes(original_init)
|
||||
boot_member = root / 'etc/s6-rc/source/boot/contents.d/getty'
|
||||
boot_member.unlink()
|
||||
reject('missing console boot member', 'Missing boot service: getty')
|
||||
boot_member.touch()
|
||||
PY
|
||||
if PROFILE=windowmaker tools/build-rootfs >"$scratch/rejection.log" 2>&1; then
|
||||
die 'Unimplemented profile was accepted'
|
||||
fi
|
||||
grep -q 'Supported profiles: cli, development' "$scratch/rejection.log"
|
||||
printf 'PASS: rejects unimplemented image profile\n'
|
||||
printf 'NOTE: run make init-test separately for native PID 1 and full ARM boot checks\n'
|
||||
printf 'SKIP: physical Pi/monitor tests and boot timings require hardware\n'
|
||||
printf 'PASS: exported-rootfs acceptance and failure-path checks\n'
|
||||
Reference in New Issue
Block a user