Skip to content

Commit 1cf8bc5

Browse files
Add recursive final-bundle Mach-O audit (#343)
Closes #328.
1 parent 348ea44 commit 1cf8bc5

8 files changed

Lines changed: 1326 additions & 5 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: macOS final-bundle audit contracts
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'src/WebScene.Sdk/CMakeLists.txt'
7+
- 'src/WebScene.Sdk/tools/audit_macos_bundle.py'
8+
- 'src/WebScene.Sdk/tools/package_macos.py'
9+
- 'src/WebScene.Sdk/schemas/webscene-macho-audit.schema.json'
10+
- 'eng/sdk/test-macos-bundle-audit.py'
11+
- 'eng/sdk/test-installed-macos-bundle-audit.py'
12+
- '.github/workflows/macos-bundle-audit.yml'
13+
workflow_dispatch:
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: macos-bundle-audit-${{ github.ref }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
portable-contract:
24+
name: Portable source and schema (${{ matrix.name }})
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
include:
29+
- name: Linux
30+
runner: ubuntu-24.04
31+
- name: Windows
32+
runner: windows-2022
33+
runs-on: ${{ matrix.runner }}
34+
timeout-minutes: 5
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: actions/setup-python@v5
38+
with:
39+
python-version: '3.12'
40+
- name: Run portable bundle-audit contracts
41+
run: python eng/sdk/test-macos-bundle-audit.py
42+
43+
installed-macos-consumer:
44+
name: Installed SDK macOS consumer
45+
runs-on: macos-15
46+
timeout-minutes: 5
47+
steps:
48+
- uses: actions/checkout@v4
49+
- name: Select Xcode toolchain
50+
shell: bash
51+
run: |
52+
set -euo pipefail
53+
developer_dir=/Applications/Xcode.app/Contents/Developer
54+
test -d "$developer_dir"
55+
echo "DEVELOPER_DIR=$developer_dir" >> "$GITHUB_ENV"
56+
DEVELOPER_DIR="$developer_dir" xcrun clang --version
57+
- name: Run portable contracts
58+
run: python3 eng/sdk/test-macos-bundle-audit.py
59+
- name: Exercise installed SDK tool against real Mach-O files
60+
run: python3 eng/sdk/test-installed-macos-bundle-audit.py
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
"""Exercise the installed SDK audit tool against real macOS Mach-O binaries."""
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from pathlib import Path
8+
import platform
9+
import resource
10+
import subprocess
11+
import sys
12+
import tempfile
13+
import time
14+
15+
16+
ROOT = Path(__file__).resolve().parents[2]
17+
18+
19+
def run(arguments, **kwargs):
20+
return subprocess.run(arguments, check=True, text=True, **kwargs)
21+
22+
23+
def main():
24+
if sys.platform != "darwin":
25+
print("installed macOS bundle audit: skipped (requires macOS)")
26+
return 0
27+
architecture = platform.machine()
28+
if architecture not in ("arm64", "x86_64"):
29+
raise RuntimeError(f"unsupported test architecture: {architecture}")
30+
with tempfile.TemporaryDirectory() as temporary:
31+
work = Path(temporary)
32+
build = work / "sdk-build"
33+
prefix = work / "sdk"
34+
run([
35+
"cmake", "-S", str(ROOT / "src/WebScene.Sdk"), "-B", str(build),
36+
"-DWEBSCENE_SDK_TOOLS_ONLY=ON", f"-DCMAKE_INSTALL_PREFIX={prefix}",
37+
], stdout=subprocess.DEVNULL)
38+
run(["cmake", "--install", str(build)], stdout=subprocess.DEVNULL)
39+
tool = prefix / "share/webscene/tools/audit_macos_bundle.py"
40+
schema_path = prefix / "share/webscene/schemas/webscene-macho-audit.schema.json"
41+
package_tool = prefix / "share/webscene/tools/package_macos.py"
42+
for installed in (tool, schema_path, package_tool):
43+
if not installed.is_file():
44+
relative = installed.relative_to(prefix)
45+
raise RuntimeError(f"installed SDK surface is missing {relative}")
46+
47+
bundle = work / "Fixture.app"
48+
macos = bundle / "Contents/MacOS"
49+
frameworks = bundle / "Contents/Frameworks"
50+
plugins = bundle / "Contents/PlugIns"
51+
for directory in (macos, frameworks, plugins):
52+
directory.mkdir(parents=True)
53+
source = work / "fixture.c"
54+
source.write_text("int fixture_value(void) { return 0; }\n")
55+
host_source = work / "host.c"
56+
host_source.write_text(
57+
"extern int fixture_value(void); int main(void) { return fixture_value(); }\n"
58+
)
59+
extension_source = work / "extension.c"
60+
extension_source.write_text(
61+
"extern int fixture_value(void); "
62+
"int extension_entry(void) { return fixture_value(); }\n"
63+
)
64+
library = frameworks / "libfixture.dylib"
65+
host = macos / "Fixture"
66+
extension = plugins / "sample.node"
67+
common = ["-arch", architecture, "-mmacosx-version-min=13.0"]
68+
run(["xcrun", "clang", *common, "-dynamiclib", str(source),
69+
"-install_name", "@rpath/libfixture.dylib", "-o", str(library)])
70+
run(["xcrun", "clang", *common, str(host_source), str(library),
71+
"-Wl,-rpath,@executable_path/../Frameworks", "-o", str(host)])
72+
run(["xcrun", "clang", *common, "-bundle", str(extension_source), str(library),
73+
"-Wl,-rpath,@loader_path/../Frameworks", "-o", str(extension)])
74+
75+
evidence_path = work / "evidence.json"
76+
command = [
77+
sys.executable, str(tool), "--bundle", str(bundle),
78+
"--executable", "Contents/MacOS/Fixture",
79+
"--architecture", architecture,
80+
"--maximum-deployment-target", "15.0",
81+
"--maximum-evidence-bytes", str(64 * 1024),
82+
"--evidence", str(evidence_path),
83+
]
84+
before_fds = len(list(Path("/dev/fd").iterdir()))
85+
before_rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
86+
started = time.monotonic()
87+
expected = None
88+
for _ in range(10):
89+
run(command, stdout=subprocess.DEVNULL)
90+
payload = evidence_path.read_bytes()
91+
if expected is None:
92+
expected = payload
93+
elif payload != expected:
94+
raise RuntimeError("installed audit evidence is not deterministic")
95+
elapsed = time.monotonic() - started
96+
after_rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
97+
after_fds = len(list(Path("/dev/fd").iterdir()))
98+
evidence = json.loads(expected)
99+
schema = json.loads(schema_path.read_text())
100+
if schema["properties"]["schemaVersion"]["const"] != evidence["schemaVersion"]:
101+
raise RuntimeError("installed evidence and schema versions differ")
102+
if evidence["summary"]["machoFiles"] != 3:
103+
raise RuntimeError("installed tool did not recursively audit all three Mach-O files")
104+
if not any(item["path"].endswith(".node") for item in evidence["binaries"]):
105+
raise RuntimeError("installed tool omitted the native extension")
106+
if os.fsencode(temporary) in expected:
107+
raise RuntimeError("installed evidence contains an absolute temporary path")
108+
if elapsed >= 10.0:
109+
raise RuntimeError(f"ten installed audits exceeded 10 seconds: {elapsed:.3f}s")
110+
if after_fds > before_fds + 2:
111+
raise RuntimeError(
112+
f"installed audits leaked file descriptors: {before_fds} -> {after_fds}"
113+
)
114+
if max(0, after_rss - before_rss) > 128 * 1024 * 1024:
115+
raise RuntimeError("installed audit child RSS grew by more than 128 MiB")
116+
if len(expected) >= 64 * 1024:
117+
raise RuntimeError("installed audit evidence exceeded 64 KiB")
118+
119+
run(["xcrun", "install_name_tool", "-change", "@rpath/libfixture.dylib",
120+
"@rpath/missing.dylib", str(extension)])
121+
rejected = subprocess.run(command, text=True, stdout=subprocess.PIPE,
122+
stderr=subprocess.STDOUT, check=False)
123+
if rejected.returncode == 0 or "unresolved bundled dependency" not in rejected.stdout:
124+
raise RuntimeError("installed tool accepted a dangling native-extension dependency")
125+
print(
126+
f"installed macOS bundle audit: 3 Mach-O files, 10 stable passes in "
127+
f"{elapsed:.3f}s, {len(expected)} evidence bytes"
128+
)
129+
return 0
130+
131+
132+
if __name__ == "__main__":
133+
raise SystemExit(main())

0 commit comments

Comments
 (0)