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
28 changes: 24 additions & 4 deletions eng/sdk/test-installed-macos-bundle-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def main():
library = frameworks / "libfixture.dylib"
host = macos / "Fixture"
extension = plugins / "sample.node"
absolute_extension = plugins / "absolute.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),
Expand All @@ -90,6 +91,9 @@ def main():
extension_slices.append(output)
run(["xcrun", "lipo", "-create", *map(str, extension_slices),
"-output", str(extension)])
producer_identity = "/Users/runner/work/native/release/deps/libabsolute.node"
run(["xcrun", "clang", *common, "-dynamiclib", str(extension_source),
"-install_name", producer_identity, "-o", str(absolute_extension)])
original_extension_bytes = extension.stat().st_size
initial_architectures = run(
["xcrun", "lipo", "-archs", str(extension)], capture_output=True
Expand Down Expand Up @@ -130,7 +134,8 @@ def main():
package_evidence = json.loads(package_payload)
normalization = package_evidence["normalization"]
extension_normalization = next(
item for item in normalization["files"] if item["path"].endswith(".node")
item for item in normalization["files"]
if item["path"] == "Contents/PlugIns/sample.node"
)
if extension_normalization["action"] != "thinned":
raise RuntimeError("installed packager did not record .node thinning")
Expand All @@ -142,6 +147,20 @@ def main():
raise RuntimeError("normalization exceeded its temporary disk bound")
if normalization["summary"]["savedBytes"] <= 0:
raise RuntimeError("normalization did not reduce the universal bundle")
if normalization["summary"]["relocatedInstallNames"] != 1:
raise RuntimeError("installed packager recorded wrong relocated identity count")
if len(normalization["installNames"]) != 1:
raise RuntimeError("installed packager omitted install-name evidence")
install_name = normalization["installNames"][0]
if (install_name["path"] != "Contents/PlugIns/absolute.node"
or install_name["original"] != producer_identity
or install_name["result"] != "@rpath/libabsolute.node"):
raise RuntimeError(f"wrong install-name evidence: {install_name}")
final_identity = run(
["xcrun", "otool", "-D", str(absolute_extension)], capture_output=True
).stdout.splitlines()[1:]
if final_identity != ["@rpath/libabsolute.node"]:
raise RuntimeError(f"absolute producer identity was not relocated: {final_identity}")
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:
Expand All @@ -164,6 +183,7 @@ def main():
loader = work / "load-extension"
run(["xcrun", "clang", *common, str(loader_source), "-o", str(loader)])
run([str(loader), str(extension)])
run([str(loader), str(absolute_extension)])

evidence_path = work / "evidence.json"
command = [
Expand Down Expand Up @@ -192,8 +212,8 @@ def main():
schema = json.loads(schema_path.read_text())
if schema["properties"]["schemaVersion"]["const"] != evidence["schemaVersion"]:
raise RuntimeError("installed evidence and schema versions differ")
if evidence["summary"]["machoFiles"] != 3:
raise RuntimeError("installed tool did not recursively audit all three Mach-O files")
if evidence["summary"]["machoFiles"] != 4:
raise RuntimeError("installed tool did not recursively audit all four Mach-O files")
if not any(item["path"].endswith(".node") for item in evidence["binaries"]):
raise RuntimeError("installed tool omitted the native extension")
if os.fsencode(temporary) in expected:
Expand All @@ -217,7 +237,7 @@ def main():
raise RuntimeError("installed tool accepted a dangling bundled dependency")
print(
f"installed macOS bundle audit: universal .node thinned, signed, loaded; "
f"3 Mach-O files, 10 stable passes in {elapsed:.3f}s, "
f"4 Mach-O files, 10 stable passes in {elapsed:.3f}s, "
f"{len(expected)} evidence bytes"
)
return 0
Expand Down
69 changes: 69 additions & 0 deletions eng/sdk/test-macos-bundle-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,29 @@ def runner(self, arguments):
raise AssertionError(arguments)


class InstallNameRunner:
def __init__(self, fixture):
self.fixture = fixture
self.calls = []

def runner(self, arguments):
self.calls.append(tuple(arguments))
if arguments[0] != "install_name_tool":
return self.fixture.runner(arguments)
if len(arguments) != 4 or arguments[1] != "-id":
raise AssertionError(arguments)
result = arguments[2]
relative = self.fixture.relative(arguments[3])
current = audit.parse_load_commands(self.fixture.commands[relative])
target = current["deploymentTargets"][0]
self.fixture.commands[relative] = load_commands(
target, current["rpaths"], identifier=result
)
path = Path(arguments[3])
path.write_bytes(path.read_bytes() + b"|relocated")
return ""


class BundleAuditTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
Expand Down Expand Up @@ -382,6 +405,7 @@ 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()
relocation_position = source.index("install_names = normalize_absolute_install_names(")
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(")
Expand All @@ -391,6 +415,7 @@ def test_packager_runs_audit_before_outer_signature(self):
verification_position = source.index(
"run('codesign', '--verify', '--deep', '--strict', str(bundle))"
)
self.assertLess(relocation_position, normalization_position)
self.assertLess(normalization_position, audit_position)
self.assertLess(audit_position, evidence_position)
self.assertLess(evidence_position, signature_position)
Expand All @@ -399,6 +424,50 @@ def test_packager_runs_audit_before_outer_signature(self):
"--maximum-audit-entries", "--maximum-audit-evidence-bytes"):
self.assertIn(option, source)

def test_absolute_application_install_names_are_relocated_before_audit(self):
absolute = "/Users/runner/work/product/release/deps/libsample.node"
relative = "Contents/PlugIns/sample.node"
self.fixture.commands[relative] = load_commands(
rpaths=("@loader_path/../Frameworks",), identifier=absolute
)
self.fixture.dependencies[relative].insert(0, absolute)
runner = InstallNameRunner(self.fixture)
original = self.fixture.extension.read_bytes()
evidence = audit.normalize_absolute_install_names(
self.fixture.bundle, runner=runner.runner
)
self.assertEqual(len(evidence), 1)
self.assertEqual(evidence[0]["path"], relative)
self.assertEqual(evidence[0]["original"], absolute)
self.assertEqual(evidence[0]["result"], "@rpath/libsample.node")
self.assertNotEqual(self.fixture.extension.read_bytes(), original)
self.fixture.dependencies[relative][0] = "@rpath/libsample.node"
audited = self.fixture.run()
extension = next(item for item in audited["binaries"]
if item["path"] == relative)
self.assertEqual(extension["slices"][0]["installName"],
"@rpath/libsample.node")
stable = self.fixture.extension.read_bytes()
self.assertEqual(audit.normalize_absolute_install_names(
self.fixture.bundle, runner=runner.runner
), [])
self.assertEqual(self.fixture.extension.read_bytes(), stable)

def test_install_name_normalization_rejects_different_slice_identities(self):
fixture = Fixture(Path(self.temporary.name) / "different", ("arm64", "x86_64"))
relative = "Contents/PlugIns/sample.node"
fixture.commands[relative] = {
"arm64": load_commands(identifier="/tmp/one.node"),
"x86_64": load_commands(identifier="/tmp/two.node"),
}
original = fixture.extension.read_bytes()
runner = InstallNameRunner(fixture)
with self.assertRaisesRegex(audit.AuditError, "different dylib install names"):
audit.normalize_absolute_install_names(
fixture.bundle, runner=runner.runner
)
self.assertEqual(fixture.extension.read_bytes(), original)

def test_packager_import_does_not_mutate_installed_tools(self):
with tempfile.TemporaryDirectory(prefix="webscene immutable tools ") as temporary:
tools = Path(temporary) / "share/webscene/tools"
Expand Down
21 changes: 19 additions & 2 deletions src/WebScene.Sdk/schemas/webscene-macho-audit.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
"normalization": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "requestedArchitecture", "files", "summary"],
"required": ["enabled", "requestedArchitecture", "files", "installNames", "summary"],
"properties": {
"enabled": { "type": "boolean" },
"requestedArchitecture": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9_]+$" },
Expand All @@ -117,13 +117,30 @@
}
}
},
"installNames": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "architectures", "original", "result", "originalSha256", "resultSha256"],
"properties": {
"path": { "$ref": "#/$defs/path" },
"architectures": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9_]+$" } },
"original": { "type": "string", "pattern": "^/" },
"result": { "type": "string", "pattern": "^@rpath/[^/]+$" },
"originalSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"resultSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": ["machoFiles", "thinnedFiles", "originalBytes", "resultBytes", "savedBytes", "peakTemporaryBytes"],
"required": ["machoFiles", "thinnedFiles", "relocatedInstallNames", "originalBytes", "resultBytes", "savedBytes", "peakTemporaryBytes"],
"properties": {
"machoFiles": { "type": "integer", "minimum": 0 },
"thinnedFiles": { "type": "integer", "minimum": 0 },
"relocatedInstallNames": { "type": "integer", "minimum": 0 },
"originalBytes": { "type": "integer", "minimum": 0 },
"resultBytes": { "type": "integer", "minimum": 0 },
"savedBytes": { "type": "integer", "minimum": 0 },
Expand Down
91 changes: 91 additions & 0 deletions src/WebScene.Sdk/tools/audit_macos_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,11 @@ def normalize_single_architecture(bundle: Path, architecture: str,
"enabled": True,
"requestedArchitecture": architecture,
"files": results,
"installNames": [],
"summary": {
"machoFiles": len(results),
"thinnedFiles": sum(item["action"] == "thinned" for item in results),
"relocatedInstallNames": 0,
"originalBytes": original_bytes,
"resultBytes": result_bytes,
"savedBytes": original_bytes - result_bytes,
Expand All @@ -369,6 +371,93 @@ def normalize_single_architecture(bundle: Path, architecture: str,
}


def normalize_absolute_install_names(
bundle: Path,
maximum_entries: int = DEFAULT_MAXIMUM_ENTRIES,
system_prefixes: tuple[str, ...] = DEFAULT_SYSTEM_PREFIXES,
runner=run_tool) -> list[dict]:
"""Relocate producer-machine LC_ID_DYLIB values without relaxing the audit."""
bundle = bundle.resolve()
before = snapshot_tree(bundle, maximum_entries)
candidates = []
for relative in sorted(path for path, entry in before.items()
if entry["type"] == "file" and entry.get("macho")):
entry = before[relative]
if os.name != "nt" and entry["links"] != 1:
raise AuditError(f"Mach-O has multiple hard links: {relative}")
path = bundle / relative
architectures = parse_architectures(runner(["lipo", "-archs", str(path)]))
identifiers = []
for architecture in architectures:
identifier = parse_load_commands(
runner(["otool", "-arch", architecture, "-l", str(path)])
)["identifier"]
identifiers.append(identifier)
distinct = set(identifiers)
if len(distinct) > 1:
raise AuditError(f"Mach-O slices have different dylib install names: {relative}")
identifier = identifiers[0]
if identifier is None or not identifier.startswith("/"):
continue
if any(identifier.startswith(prefix) for prefix in system_prefixes):
continue
basename = PurePosixPath(identifier).name
if not basename or basename in (".", ".."):
raise AuditError(f"absolute dylib install name has no basename: {relative}")
relocated = "@rpath/" + basename
validate_install_name(relocated, "relocated dylib install name", system_prefixes)
candidates.append({
"path": relative,
"architectures": architectures,
"original": identifier,
"result": relocated,
"originalSha256": sha256(path),
})

results = []
for candidate in candidates:
relative = candidate["path"]
path = bundle / relative
original = before[relative]
if (stable_normalization_metadata(file_metadata(path))
!= stable_normalization_metadata(original)
or sha256(path) != candidate["originalSha256"]):
raise AuditError(f"Mach-O mutated before install-name normalization: {relative}")
runner(["install_name_tool", "-id", candidate["result"], str(path)])
current = file_metadata(path)
if (current["type"] != "file" or not current.get("macho")
or (os.name != "nt" and (
current["links"] != 1
or current["mode"] != original["mode"]
or current["uid"] != original["uid"]
or current["gid"] != original["gid"]))):
raise AuditError(f"install-name normalization made Mach-O unsafe: {relative}")
for architecture in candidate["architectures"]:
identifier = parse_load_commands(
runner(["otool", "-arch", architecture, "-l", str(path)])
)["identifier"]
if identifier != candidate["result"]:
raise AuditError(
f"install-name normalization failed for {relative} ({architecture})"
)
result_hash = sha256(path)
if result_hash == candidate["originalSha256"]:
raise AuditError(f"install-name normalization did not modify {relative}")
results.append({**candidate, "resultSha256": result_hash})

after = snapshot_tree(bundle, maximum_entries)
if set(before) != set(after):
changed = sorted(set(before) ^ set(after))
raise AuditError(f"bundle inventory changed during install-name normalization: {changed[0]}")
changed_paths = {item["path"] for item in results}
for relative in sorted(before):
if relative in changed_paths:
continue
if stable_metadata(before[relative]) != stable_metadata(after[relative]):
raise AuditError(f"bundle mutated during install-name normalization: {relative}")
return results


def parse_architectures(output: str) -> list[str]:
architectures = output.strip().split()
if not architectures or any(
Expand Down Expand Up @@ -717,9 +806,11 @@ def audit_bundle(bundle: Path, executable: str, architectures: list[str],
"enabled": False,
"requestedArchitecture": None,
"files": [],
"installNames": [],
"summary": {
"machoFiles": len(binaries),
"thinnedFiles": 0,
"relocatedInstallNames": 0,
"originalBytes": sum(item["bytes"] for item in binaries),
"resultBytes": sum(item["bytes"] for item in binaries),
"savedBytes": 0,
Expand Down
11 changes: 11 additions & 0 deletions src/WebScene.Sdk/tools/package_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
audit_bundle,
default_deployment_target,
encode_evidence,
normalize_absolute_install_names,
normalize_single_architecture,
)

Expand Down Expand Up @@ -101,6 +102,10 @@ def package(args):
maximum_deployment_target = (
args.maximum_deployment_target or default_deployment_target(bundle)
)
install_names = normalize_absolute_install_names(
bundle,
maximum_entries=args.maximum_audit_entries,
)
normalization = None
if len(architectures) == 1:
normalization = normalize_single_architecture(
Expand All @@ -115,6 +120,12 @@ def package(args):
maximum_deployment_target,
maximum_entries=args.maximum_audit_entries,
)
normalization = (
pre_signature_evidence['normalization']
if normalization is None else normalization
)
normalization['installNames'] = install_names
normalization['summary']['relocatedInstallNames'] = len(install_names)
for item in sorted(
pre_signature_evidence['binaries'],
key=lambda value: len(Path(value['path']).parts), reverse=True):
Expand Down
Loading