Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 87 additions & 10 deletions eng/sdk/test-installed-macos-bundle-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
from pathlib import Path
import platform
import plistlib
import resource
import subprocess
import sys
Expand Down Expand Up @@ -50,27 +51,102 @@ def main():
plugins = bundle / "Contents/PlugIns"
for directory in (macos, frameworks, plugins):
directory.mkdir(parents=True)
with (bundle / "Contents/Info.plist").open("wb") as stream:
plistlib.dump({
"CFBundleExecutable": "Fixture",
"CFBundleIdentifier": "dev.webscene.audit-fixture",
"CFBundlePackageType": "APPL",
"LSMinimumSystemVersion": "13.0",
}, stream)
source = work / "fixture.c"
source.write_text("int fixture_value(void) { return 0; }\n")
host_source = work / "host.c"
host_source.write_text(
"extern int fixture_value(void); int main(void) { return fixture_value(); }\n"
)
extension_source = work / "extension.c"
extension_source.write_text(
"extern int fixture_value(void); "
"int extension_entry(void) { return fixture_value(); }\n"
)
extension_source.write_text("int extension_entry(void) { return 42; }\n")
library = frameworks / "libfixture.dylib"
host = macos / "Fixture"
extension = plugins / "sample.node"
alternate_architecture = "x86_64" if architecture == "arm64" else "arm64"
common = ["-arch", architecture, "-mmacosx-version-min=13.0"]
run(["xcrun", "clang", *common, "-dynamiclib", str(source),
"-install_name", "@rpath/libfixture.dylib", "-o", str(library)])
run(["xcrun", "clang", *common, str(host_source), str(library),
"-Wl,-rpath,@executable_path/../Frameworks", "-o", str(host)])
run(["xcrun", "clang", *common, "-bundle", str(extension_source), str(library),
"-Wl,-rpath,@loader_path/../Frameworks", "-o", str(extension)])
extension_slices = []
for slice_architecture in (architecture, alternate_architecture):
output = work / f"sample-{slice_architecture}.node"
run([
"xcrun", "clang", "-arch", slice_architecture,
"-mmacosx-version-min=13.0", "-bundle", str(extension_source),
"-o", str(output),
])
extension_slices.append(output)
run(["xcrun", "lipo", "-create", *map(str, extension_slices),
"-output", str(extension)])
original_extension_bytes = extension.stat().st_size
initial_architectures = run(
["xcrun", "lipo", "-archs", str(extension)], capture_output=True
).stdout.split()
if set(initial_architectures) != {"arm64", "x86_64"}:
raise RuntimeError(f"fixture is not universal: {initial_architectures}")

run([
sys.executable, str(package_tool), "--bundle", str(bundle),
"--sdk", str(prefix), "--executable", "Fixture",
"--architecture", architecture,
"--maximum-deployment-target", "15.0",
"--maximum-audit-entries", "10000",
"--maximum-audit-evidence-bytes", str(64 * 1024),
], stdout=subprocess.DEVNULL)
final_architectures = run(
["xcrun", "lipo", "-archs", str(extension)], capture_output=True
).stdout.split()
if final_architectures != [architecture]:
raise RuntimeError(f"installed packager did not thin .node: {final_architectures}")
package_evidence_path = (
bundle / "Contents/Resources/webscene-macho-audit.json"
)
package_payload = package_evidence_path.read_bytes()
package_evidence = json.loads(package_payload)
normalization = package_evidence["normalization"]
extension_normalization = next(
item for item in normalization["files"] if item["path"].endswith(".node")
)
if extension_normalization["action"] != "thinned":
raise RuntimeError("installed packager did not record .node thinning")
if set(extension_normalization["originalArchitectures"]) != {"arm64", "x86_64"}:
raise RuntimeError("installed packager recorded wrong original .node slices")
if extension_normalization["resultArchitectures"] != [architecture]:
raise RuntimeError("installed packager recorded wrong resulting .node slice")
if normalization["summary"]["peakTemporaryBytes"] > original_extension_bytes:
raise RuntimeError("normalization exceeded its temporary disk bound")
if normalization["summary"]["savedBytes"] <= 0:
raise RuntimeError("normalization did not reduce the universal bundle")
if any(".webscene-thin-" in item.name for item in plugins.iterdir()):
raise RuntimeError("normalization left a temporary file")
if os.fsencode(temporary) in package_payload:
raise RuntimeError("package evidence contains an absolute temporary path")
run(["xcrun", "codesign", "--verify", "--deep", "--strict", str(bundle)])

loader_source = work / "loader.c"
loader_source.write_text(
"#include <dlfcn.h>\n"
"typedef int (*entry_t)(void);\n"
"int main(int argc, char **argv) {\n"
" if (argc != 2) return 2;\n"
" void *handle = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);\n"
" if (!handle) return 3;\n"
" entry_t entry = (entry_t)dlsym(handle, \"extension_entry\");\n"
" if (!entry || entry() != 42) return 4;\n"
" return dlclose(handle) == 0 ? 0 : 5;\n"
"}\n"
)
loader = work / "load-extension"
run(["xcrun", "clang", *common, str(loader_source), "-o", str(loader)])
run([str(loader), str(extension)])

evidence_path = work / "evidence.json"
command = [
Expand Down Expand Up @@ -117,14 +193,15 @@ def main():
raise RuntimeError("installed audit evidence exceeded 64 KiB")

run(["xcrun", "install_name_tool", "-change", "@rpath/libfixture.dylib",
"@rpath/missing.dylib", str(extension)])
"@rpath/missing.dylib", str(host)])
rejected = subprocess.run(command, text=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, check=False)
if rejected.returncode == 0 or "unresolved bundled dependency" not in rejected.stdout:
raise RuntimeError("installed tool accepted a dangling native-extension dependency")
raise RuntimeError("installed tool accepted a dangling bundled dependency")
print(
f"installed macOS bundle audit: 3 Mach-O files, 10 stable passes in "
f"{elapsed:.3f}s, {len(expected)} evidence bytes"
f"installed macOS bundle audit: universal .node thinned, signed, loaded; "
f"3 Mach-O files, 10 stable passes in {elapsed:.3f}s, "
f"{len(expected)} evidence bytes"
)
return 0

Expand Down
177 changes: 176 additions & 1 deletion eng/sdk/test-macos-bundle-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ def run(self, **kwargs):
)


class NormalizationRunner:
def __init__(self, bundle, architectures=None, behavior="success"):
self.bundle = bundle.resolve()
self.architectures = architectures or {}
self.outputs = {}
self.behavior = behavior
self.calls = []

def runner(self, arguments):
self.calls.append(tuple(arguments))
if arguments[:2] == ["lipo", "-archs"]:
path = Path(arguments[2]).resolve()
values = self.outputs.get(str(path))
if values is None:
relative = path.relative_to(self.bundle).as_posix()
values = self.architectures.get(relative, ["arm64"])
return " ".join(values) + "\n"
if len(arguments) == 6 and arguments[0] == "lipo" and arguments[2] == "-thin":
source = Path(arguments[1])
architecture = arguments[3]
output = Path(arguments[5])
if self.behavior == "interrupt":
output.write_bytes(MAGIC + b"partial")
raise audit.AuditError("simulated interrupted lipo")
if self.behavior == "mutate":
source.write_bytes(source.read_bytes() + b"changed")
if self.behavior == "malformed":
output.write_text("not a Mach-O")
elif self.behavior == "grow":
output.write_bytes(source.read_bytes() + b"unexpected growth")
else:
data = source.read_bytes()
output.write_bytes(data[:4] + data[4:max(8, len(data) // 2)])
self.outputs[str(output.resolve())] = [architecture]
return ""
raise AssertionError(arguments)


class BundleAuditTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
Expand Down Expand Up @@ -342,21 +380,158 @@ def test_schema_and_evidence_contract(self):

def test_packager_runs_audit_before_outer_signature(self):
source = (ROOT / "src/WebScene.Sdk/tools/package_macos.py").read_text()
audit_position = source.index("evidence = audit_bundle(")
normalization_position = source.index("normalization = normalize_single_architecture(")
audit_position = source.index("pre_signature_evidence = audit_bundle(")
evidence_position = source.index("evidence_path.write_bytes(")
signature_position = source.index(
"run('codesign', '--force', '--sign', '-', str(bundle))"
)
verification_position = source.index(
"run('codesign', '--verify', '--deep', '--strict', str(bundle))"
)
self.assertLess(normalization_position, audit_position)
self.assertLess(audit_position, evidence_position)
self.assertLess(evidence_position, signature_position)
self.assertLess(signature_position, verification_position)
for option in ("--architecture", "--maximum-deployment-target",
"--maximum-audit-entries", "--maximum-audit-evidence-bytes"):
self.assertIn(option, source)

def test_single_architecture_normalization_is_atomic_and_idempotent(self):
extension_relative = "Contents/PlugIns/sample.node"
self.fixture.extension.write_bytes(b"\xca\xfe\xba\xbf" + bytes(range(128)))
if os.name != "nt":
self.fixture.extension.chmod(0o555)
original_mtime = self.fixture.extension.stat().st_mtime_ns
runner = NormalizationRunner(
self.fixture.bundle,
{extension_relative: ["arm64", "x86_64"]},
)
evidence = audit.normalize_single_architecture(
self.fixture.bundle, "arm64", runner=runner.runner
)
extension = next(item for item in evidence["files"]
if item["path"] == extension_relative)
self.assertEqual(extension["action"], "thinned")
self.assertEqual(extension["originalArchitectures"], ["arm64", "x86_64"])
self.assertEqual(extension["resultArchitectures"], ["arm64"])
self.assertLess(extension["resultBytes"], extension["originalBytes"])
self.assertEqual(evidence["summary"]["peakTemporaryBytes"],
extension["resultBytes"])
if os.name != "nt":
self.assertEqual(self.fixture.extension.stat().st_mode & 0o777, 0o555)
self.assertEqual(self.fixture.extension.stat().st_mtime_ns, original_mtime)
self.assertFalse(any(".webscene-thin-" in item.name
for item in self.fixture.extension.parent.iterdir()))
stable = (self.fixture.extension.read_bytes(),
self.fixture.extension.stat().st_mtime_ns)
repeated = audit.normalize_single_architecture(
self.fixture.bundle, "arm64",
runner=NormalizationRunner(self.fixture.bundle).runner
)
self.assertEqual((self.fixture.extension.read_bytes(),
self.fixture.extension.stat().st_mtime_ns), stable)
self.assertEqual(repeated["summary"]["thinnedFiles"], 0)

def test_missing_slice_preflight_leaves_every_binary_unchanged(self):
paths = {
relative: (self.fixture.bundle / relative).read_bytes()
for relative in self.fixture.commands
}
runner = NormalizationRunner(self.fixture.bundle, {
"Contents/Frameworks/libfixture.dylib": ["arm64", "x86_64"],
"Contents/PlugIns/sample.node": ["x86_64"],
})
with self.assertRaisesRegex(audit.AuditError, "absent.*sample[.]node"):
audit.normalize_single_architecture(
self.fixture.bundle, "arm64", runner=runner.runner
)
for relative, data in paths.items():
self.assertEqual((self.fixture.bundle / relative).read_bytes(), data)

def test_interrupted_and_malformed_lipo_leave_no_partial_file(self):
relative = "Contents/PlugIns/sample.node"
for behavior, pattern in (("interrupt", "interrupted"),
("malformed", "unsafe non-Mach-O"),
("grow", "result grew")):
with self.subTest(behavior=behavior):
fixture = Fixture(Path(self.temporary.name) / behavior)
original = fixture.extension.read_bytes()
runner = NormalizationRunner(
fixture.bundle, {relative: ["arm64", "x86_64"]}, behavior
)
with self.assertRaisesRegex(audit.AuditError, pattern):
audit.normalize_single_architecture(
fixture.bundle, "arm64", runner=runner.runner
)
self.assertEqual(fixture.extension.read_bytes(), original)
self.assertFalse(any(".webscene-thin-" in item.name
for item in fixture.extension.parent.iterdir()))

def test_source_mutation_during_lipo_is_rejected_without_replacement(self):
relative = "Contents/PlugIns/sample.node"
original = self.fixture.extension.read_bytes()
runner = NormalizationRunner(
self.fixture.bundle, {relative: ["arm64", "x86_64"]}, "mutate"
)
with self.assertRaisesRegex(audit.AuditError, "mutated during normalization"):
audit.normalize_single_architecture(
self.fixture.bundle, "arm64", runner=runner.runner
)
self.assertEqual(self.fixture.extension.read_bytes(), original + b"changed")
self.assertFalse(any(".webscene-thin-" in item.name
for item in self.fixture.extension.parent.iterdir()))

@unittest.skipIf(os.name == "nt", "hard-link fixture uses Unix bundle semantics")
def test_hardlinked_macho_is_rejected(self):
alias = self.fixture.bundle / "Contents/PlugIns/alias.node"
os.link(self.fixture.extension, alias)
with self.assertRaisesRegex(audit.AuditError, "multiple hard links"):
audit.normalize_single_architecture(
self.fixture.bundle, "arm64",
runner=NormalizationRunner(self.fixture.bundle).runner
)

def test_normalization_bounds_ten_thousand_entries(self):
fixture = Fixture(Path(self.temporary.name) / "normalize-scale")
fixture.extension.write_bytes(b"\xca\xfe\xba\xbf" + bytes(range(128)))
payload = fixture.bundle / "payload"
payload.mkdir()
for index in range(9_992):
(payload / f"{index:04x}").write_bytes(b"")
fd_root = (Path("/proc/self/fd") if Path("/proc/self/fd").is_dir()
else Path("/dev/fd"))
before_fds = len(list(fd_root.iterdir())) if fd_root.is_dir() else None
before_rss = (resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
if resource is not None else None)
runner = NormalizationRunner(fixture.bundle, {
"Contents/PlugIns/sample.node": ["arm64", "x86_64"],
})
started = time.monotonic()
evidence = audit.normalize_single_architecture(
fixture.bundle, "arm64", maximum_entries=10_000,
runner=runner.runner,
)
elapsed = time.monotonic() - started
after_rss = (resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
if resource is not None else None)
after_fds = len(list(fd_root.iterdir())) if fd_root.is_dir() else None
self.assertEqual(evidence["summary"]["machoFiles"], 3)
self.assertEqual(evidence["summary"]["thinnedFiles"], 1)
self.assertGreater(evidence["summary"]["peakTemporaryBytes"], 0)
self.assertLessEqual(evidence["summary"]["peakTemporaryBytes"], 132)
self.assertFalse(any(".webscene-thin-" in item.name
for item in fixture.extension.parent.iterdir()))
self.assertLess(elapsed, 15.0)
if before_fds is not None:
self.assertLessEqual(after_fds, before_fds + 2)
if before_rss is not None:
rss_multiplier = 1 if sys.platform == "darwin" else 1024
self.assertLessEqual(max(0, after_rss - before_rss) * rss_multiplier,
128 * 1024 * 1024)
self.assertLess(len(audit.encode_evidence(
{"normalization": evidence}, 64 * 1024)), 64 * 1024)

def test_ten_thousand_entry_limits_time_fds_rss_and_evidence(self):
fixture = Fixture(Path(self.temporary.name) / "scale")
# Five directories + three Mach-O files + 9,992 payload files = 10,000 entries.
Expand Down
11 changes: 8 additions & 3 deletions src/WebScene.Sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ allowlisted-system dependency closure. The scan rejects escaping, dangling, and
case-mismatched paths, unsafe symlinks, concurrent bundle mutation, and bundles over
the configured entry or evidence limits.

`package_macos.py` runs the audit after it has copied, stripped, and individually
signed native files, writes `Contents/Resources/webscene-macho-audit.json`, and then
signs and verifies the outer bundle. The evidence format is installed at
For a single-architecture package, `package_macos.py` first thins every universal
Mach-O file with a validated same-directory atomic replacement. It preserves file
mode, ownership, and modification time and leaves already-thin inputs byte-for-byte
unchanged. The packager then audits the complete normalized graph, signs every nested
Mach-O file, audits the signed graph into
`Contents/Resources/webscene-macho-audit.json`, and signs and verifies the outer
bundle. The evidence records each normalization action, architecture set, size, and
digest. The evidence format is installed at
`share/webscene/schemas/webscene-macho-audit.schema.json`. Consumers can also run the
audit directly:

Expand Down
Loading
Loading