Skip to content

Commit d2e4c72

Browse files
Merge pull request #412 from SceneTech/fix/macos-app-macho-identities-410
Relocate application Mach-O identities before bundle audit
2 parents 5415814 + 4afbfa5 commit d2e4c72

5 files changed

Lines changed: 214 additions & 6 deletions

File tree

‎eng/sdk/test-installed-macos-bundle-audit.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def main():
7373
library = frameworks / "libfixture.dylib"
7474
host = macos / "Fixture"
7575
extension = plugins / "sample.node"
76+
absolute_extension = plugins / "absolute.node"
7677
alternate_architecture = "x86_64" if architecture == "arm64" else "arm64"
7778
common = ["-arch", architecture, "-mmacosx-version-min=13.0"]
7879
run(["xcrun", "clang", *common, "-dynamiclib", str(source),
@@ -90,6 +91,9 @@ def main():
9091
extension_slices.append(output)
9192
run(["xcrun", "lipo", "-create", *map(str, extension_slices),
9293
"-output", str(extension)])
94+
producer_identity = "/Users/runner/work/native/release/deps/libabsolute.node"
95+
run(["xcrun", "clang", *common, "-dynamiclib", str(extension_source),
96+
"-install_name", producer_identity, "-o", str(absolute_extension)])
9397
original_extension_bytes = extension.stat().st_size
9498
initial_architectures = run(
9599
["xcrun", "lipo", "-archs", str(extension)], capture_output=True
@@ -130,7 +134,8 @@ def main():
130134
package_evidence = json.loads(package_payload)
131135
normalization = package_evidence["normalization"]
132136
extension_normalization = next(
133-
item for item in normalization["files"] if item["path"].endswith(".node")
137+
item for item in normalization["files"]
138+
if item["path"] == "Contents/PlugIns/sample.node"
134139
)
135140
if extension_normalization["action"] != "thinned":
136141
raise RuntimeError("installed packager did not record .node thinning")
@@ -142,6 +147,20 @@ def main():
142147
raise RuntimeError("normalization exceeded its temporary disk bound")
143148
if normalization["summary"]["savedBytes"] <= 0:
144149
raise RuntimeError("normalization did not reduce the universal bundle")
150+
if normalization["summary"]["relocatedInstallNames"] != 1:
151+
raise RuntimeError("installed packager recorded wrong relocated identity count")
152+
if len(normalization["installNames"]) != 1:
153+
raise RuntimeError("installed packager omitted install-name evidence")
154+
install_name = normalization["installNames"][0]
155+
if (install_name["path"] != "Contents/PlugIns/absolute.node"
156+
or install_name["original"] != producer_identity
157+
or install_name["result"] != "@rpath/libabsolute.node"):
158+
raise RuntimeError(f"wrong install-name evidence: {install_name}")
159+
final_identity = run(
160+
["xcrun", "otool", "-D", str(absolute_extension)], capture_output=True
161+
).stdout.splitlines()[1:]
162+
if final_identity != ["@rpath/libabsolute.node"]:
163+
raise RuntimeError(f"absolute producer identity was not relocated: {final_identity}")
145164
if any(".webscene-thin-" in item.name for item in plugins.iterdir()):
146165
raise RuntimeError("normalization left a temporary file")
147166
if os.fsencode(temporary) in package_payload:
@@ -164,6 +183,7 @@ def main():
164183
loader = work / "load-extension"
165184
run(["xcrun", "clang", *common, str(loader_source), "-o", str(loader)])
166185
run([str(loader), str(extension)])
186+
run([str(loader), str(absolute_extension)])
167187

168188
evidence_path = work / "evidence.json"
169189
command = [
@@ -192,8 +212,8 @@ def main():
192212
schema = json.loads(schema_path.read_text())
193213
if schema["properties"]["schemaVersion"]["const"] != evidence["schemaVersion"]:
194214
raise RuntimeError("installed evidence and schema versions differ")
195-
if evidence["summary"]["machoFiles"] != 3:
196-
raise RuntimeError("installed tool did not recursively audit all three Mach-O files")
215+
if evidence["summary"]["machoFiles"] != 4:
216+
raise RuntimeError("installed tool did not recursively audit all four Mach-O files")
197217
if not any(item["path"].endswith(".node") for item in evidence["binaries"]):
198218
raise RuntimeError("installed tool omitted the native extension")
199219
if os.fsencode(temporary) in expected:
@@ -217,7 +237,7 @@ def main():
217237
raise RuntimeError("installed tool accepted a dangling bundled dependency")
218238
print(
219239
f"installed macOS bundle audit: universal .node thinned, signed, loaded; "
220-
f"3 Mach-O files, 10 stable passes in {elapsed:.3f}s, "
240+
f"4 Mach-O files, 10 stable passes in {elapsed:.3f}s, "
221241
f"{len(expected)} evidence bytes"
222242
)
223243
return 0

‎eng/sdk/test-macos-bundle-audit.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,29 @@ def runner(self, arguments):
173173
raise AssertionError(arguments)
174174

175175

176+
class InstallNameRunner:
177+
def __init__(self, fixture):
178+
self.fixture = fixture
179+
self.calls = []
180+
181+
def runner(self, arguments):
182+
self.calls.append(tuple(arguments))
183+
if arguments[0] != "install_name_tool":
184+
return self.fixture.runner(arguments)
185+
if len(arguments) != 4 or arguments[1] != "-id":
186+
raise AssertionError(arguments)
187+
result = arguments[2]
188+
relative = self.fixture.relative(arguments[3])
189+
current = audit.parse_load_commands(self.fixture.commands[relative])
190+
target = current["deploymentTargets"][0]
191+
self.fixture.commands[relative] = load_commands(
192+
target, current["rpaths"], identifier=result
193+
)
194+
path = Path(arguments[3])
195+
path.write_bytes(path.read_bytes() + b"|relocated")
196+
return ""
197+
198+
176199
class BundleAuditTests(unittest.TestCase):
177200
def setUp(self):
178201
self.temporary = tempfile.TemporaryDirectory()
@@ -382,6 +405,7 @@ def test_schema_and_evidence_contract(self):
382405

383406
def test_packager_runs_audit_before_outer_signature(self):
384407
source = (ROOT / "src/WebScene.Sdk/tools/package_macos.py").read_text()
408+
relocation_position = source.index("install_names = normalize_absolute_install_names(")
385409
normalization_position = source.index("normalization = normalize_single_architecture(")
386410
audit_position = source.index("pre_signature_evidence = audit_bundle(")
387411
evidence_position = source.index("evidence_path.write_bytes(")
@@ -391,6 +415,7 @@ def test_packager_runs_audit_before_outer_signature(self):
391415
verification_position = source.index(
392416
"run('codesign', '--verify', '--deep', '--strict', str(bundle))"
393417
)
418+
self.assertLess(relocation_position, normalization_position)
394419
self.assertLess(normalization_position, audit_position)
395420
self.assertLess(audit_position, evidence_position)
396421
self.assertLess(evidence_position, signature_position)
@@ -399,6 +424,50 @@ def test_packager_runs_audit_before_outer_signature(self):
399424
"--maximum-audit-entries", "--maximum-audit-evidence-bytes"):
400425
self.assertIn(option, source)
401426

427+
def test_absolute_application_install_names_are_relocated_before_audit(self):
428+
absolute = "/Users/runner/work/product/release/deps/libsample.node"
429+
relative = "Contents/PlugIns/sample.node"
430+
self.fixture.commands[relative] = load_commands(
431+
rpaths=("@loader_path/../Frameworks",), identifier=absolute
432+
)
433+
self.fixture.dependencies[relative].insert(0, absolute)
434+
runner = InstallNameRunner(self.fixture)
435+
original = self.fixture.extension.read_bytes()
436+
evidence = audit.normalize_absolute_install_names(
437+
self.fixture.bundle, runner=runner.runner
438+
)
439+
self.assertEqual(len(evidence), 1)
440+
self.assertEqual(evidence[0]["path"], relative)
441+
self.assertEqual(evidence[0]["original"], absolute)
442+
self.assertEqual(evidence[0]["result"], "@rpath/libsample.node")
443+
self.assertNotEqual(self.fixture.extension.read_bytes(), original)
444+
self.fixture.dependencies[relative][0] = "@rpath/libsample.node"
445+
audited = self.fixture.run()
446+
extension = next(item for item in audited["binaries"]
447+
if item["path"] == relative)
448+
self.assertEqual(extension["slices"][0]["installName"],
449+
"@rpath/libsample.node")
450+
stable = self.fixture.extension.read_bytes()
451+
self.assertEqual(audit.normalize_absolute_install_names(
452+
self.fixture.bundle, runner=runner.runner
453+
), [])
454+
self.assertEqual(self.fixture.extension.read_bytes(), stable)
455+
456+
def test_install_name_normalization_rejects_different_slice_identities(self):
457+
fixture = Fixture(Path(self.temporary.name) / "different", ("arm64", "x86_64"))
458+
relative = "Contents/PlugIns/sample.node"
459+
fixture.commands[relative] = {
460+
"arm64": load_commands(identifier="/tmp/one.node"),
461+
"x86_64": load_commands(identifier="/tmp/two.node"),
462+
}
463+
original = fixture.extension.read_bytes()
464+
runner = InstallNameRunner(fixture)
465+
with self.assertRaisesRegex(audit.AuditError, "different dylib install names"):
466+
audit.normalize_absolute_install_names(
467+
fixture.bundle, runner=runner.runner
468+
)
469+
self.assertEqual(fixture.extension.read_bytes(), original)
470+
402471
def test_packager_import_does_not_mutate_installed_tools(self):
403472
with tempfile.TemporaryDirectory(prefix="webscene immutable tools ") as temporary:
404473
tools = Path(temporary) / "share/webscene/tools"

‎src/WebScene.Sdk/schemas/webscene-macho-audit.schema.json‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
"normalization": {
9696
"type": "object",
9797
"additionalProperties": false,
98-
"required": ["enabled", "requestedArchitecture", "files", "summary"],
98+
"required": ["enabled", "requestedArchitecture", "files", "installNames", "summary"],
9999
"properties": {
100100
"enabled": { "type": "boolean" },
101101
"requestedArchitecture": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9_]+$" },
@@ -117,13 +117,30 @@
117117
}
118118
}
119119
},
120+
"installNames": {
121+
"type": "array",
122+
"items": {
123+
"type": "object",
124+
"additionalProperties": false,
125+
"required": ["path", "architectures", "original", "result", "originalSha256", "resultSha256"],
126+
"properties": {
127+
"path": { "$ref": "#/$defs/path" },
128+
"architectures": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9_]+$" } },
129+
"original": { "type": "string", "pattern": "^/" },
130+
"result": { "type": "string", "pattern": "^@rpath/[^/]+$" },
131+
"originalSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
132+
"resultSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
133+
}
134+
}
135+
},
120136
"summary": {
121137
"type": "object",
122138
"additionalProperties": false,
123-
"required": ["machoFiles", "thinnedFiles", "originalBytes", "resultBytes", "savedBytes", "peakTemporaryBytes"],
139+
"required": ["machoFiles", "thinnedFiles", "relocatedInstallNames", "originalBytes", "resultBytes", "savedBytes", "peakTemporaryBytes"],
124140
"properties": {
125141
"machoFiles": { "type": "integer", "minimum": 0 },
126142
"thinnedFiles": { "type": "integer", "minimum": 0 },
143+
"relocatedInstallNames": { "type": "integer", "minimum": 0 },
127144
"originalBytes": { "type": "integer", "minimum": 0 },
128145
"resultBytes": { "type": "integer", "minimum": 0 },
129146
"savedBytes": { "type": "integer", "minimum": 0 },

‎src/WebScene.Sdk/tools/audit_macos_bundle.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,11 @@ def normalize_single_architecture(bundle: Path, architecture: str,
358358
"enabled": True,
359359
"requestedArchitecture": architecture,
360360
"files": results,
361+
"installNames": [],
361362
"summary": {
362363
"machoFiles": len(results),
363364
"thinnedFiles": sum(item["action"] == "thinned" for item in results),
365+
"relocatedInstallNames": 0,
364366
"originalBytes": original_bytes,
365367
"resultBytes": result_bytes,
366368
"savedBytes": original_bytes - result_bytes,
@@ -369,6 +371,93 @@ def normalize_single_architecture(bundle: Path, architecture: str,
369371
}
370372

371373

374+
def normalize_absolute_install_names(
375+
bundle: Path,
376+
maximum_entries: int = DEFAULT_MAXIMUM_ENTRIES,
377+
system_prefixes: tuple[str, ...] = DEFAULT_SYSTEM_PREFIXES,
378+
runner=run_tool) -> list[dict]:
379+
"""Relocate producer-machine LC_ID_DYLIB values without relaxing the audit."""
380+
bundle = bundle.resolve()
381+
before = snapshot_tree(bundle, maximum_entries)
382+
candidates = []
383+
for relative in sorted(path for path, entry in before.items()
384+
if entry["type"] == "file" and entry.get("macho")):
385+
entry = before[relative]
386+
if os.name != "nt" and entry["links"] != 1:
387+
raise AuditError(f"Mach-O has multiple hard links: {relative}")
388+
path = bundle / relative
389+
architectures = parse_architectures(runner(["lipo", "-archs", str(path)]))
390+
identifiers = []
391+
for architecture in architectures:
392+
identifier = parse_load_commands(
393+
runner(["otool", "-arch", architecture, "-l", str(path)])
394+
)["identifier"]
395+
identifiers.append(identifier)
396+
distinct = set(identifiers)
397+
if len(distinct) > 1:
398+
raise AuditError(f"Mach-O slices have different dylib install names: {relative}")
399+
identifier = identifiers[0]
400+
if identifier is None or not identifier.startswith("/"):
401+
continue
402+
if any(identifier.startswith(prefix) for prefix in system_prefixes):
403+
continue
404+
basename = PurePosixPath(identifier).name
405+
if not basename or basename in (".", ".."):
406+
raise AuditError(f"absolute dylib install name has no basename: {relative}")
407+
relocated = "@rpath/" + basename
408+
validate_install_name(relocated, "relocated dylib install name", system_prefixes)
409+
candidates.append({
410+
"path": relative,
411+
"architectures": architectures,
412+
"original": identifier,
413+
"result": relocated,
414+
"originalSha256": sha256(path),
415+
})
416+
417+
results = []
418+
for candidate in candidates:
419+
relative = candidate["path"]
420+
path = bundle / relative
421+
original = before[relative]
422+
if (stable_normalization_metadata(file_metadata(path))
423+
!= stable_normalization_metadata(original)
424+
or sha256(path) != candidate["originalSha256"]):
425+
raise AuditError(f"Mach-O mutated before install-name normalization: {relative}")
426+
runner(["install_name_tool", "-id", candidate["result"], str(path)])
427+
current = file_metadata(path)
428+
if (current["type"] != "file" or not current.get("macho")
429+
or (os.name != "nt" and (
430+
current["links"] != 1
431+
or current["mode"] != original["mode"]
432+
or current["uid"] != original["uid"]
433+
or current["gid"] != original["gid"]))):
434+
raise AuditError(f"install-name normalization made Mach-O unsafe: {relative}")
435+
for architecture in candidate["architectures"]:
436+
identifier = parse_load_commands(
437+
runner(["otool", "-arch", architecture, "-l", str(path)])
438+
)["identifier"]
439+
if identifier != candidate["result"]:
440+
raise AuditError(
441+
f"install-name normalization failed for {relative} ({architecture})"
442+
)
443+
result_hash = sha256(path)
444+
if result_hash == candidate["originalSha256"]:
445+
raise AuditError(f"install-name normalization did not modify {relative}")
446+
results.append({**candidate, "resultSha256": result_hash})
447+
448+
after = snapshot_tree(bundle, maximum_entries)
449+
if set(before) != set(after):
450+
changed = sorted(set(before) ^ set(after))
451+
raise AuditError(f"bundle inventory changed during install-name normalization: {changed[0]}")
452+
changed_paths = {item["path"] for item in results}
453+
for relative in sorted(before):
454+
if relative in changed_paths:
455+
continue
456+
if stable_metadata(before[relative]) != stable_metadata(after[relative]):
457+
raise AuditError(f"bundle mutated during install-name normalization: {relative}")
458+
return results
459+
460+
372461
def parse_architectures(output: str) -> list[str]:
373462
architectures = output.strip().split()
374463
if not architectures or any(
@@ -717,9 +806,11 @@ def audit_bundle(bundle: Path, executable: str, architectures: list[str],
717806
"enabled": False,
718807
"requestedArchitecture": None,
719808
"files": [],
809+
"installNames": [],
720810
"summary": {
721811
"machoFiles": len(binaries),
722812
"thinnedFiles": 0,
813+
"relocatedInstallNames": 0,
723814
"originalBytes": sum(item["bytes"] for item in binaries),
724815
"resultBytes": sum(item["bytes"] for item in binaries),
725816
"savedBytes": 0,

‎src/WebScene.Sdk/tools/package_macos.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
audit_bundle,
1919
default_deployment_target,
2020
encode_evidence,
21+
normalize_absolute_install_names,
2122
normalize_single_architecture,
2223
)
2324

@@ -101,6 +102,10 @@ def package(args):
101102
maximum_deployment_target = (
102103
args.maximum_deployment_target or default_deployment_target(bundle)
103104
)
105+
install_names = normalize_absolute_install_names(
106+
bundle,
107+
maximum_entries=args.maximum_audit_entries,
108+
)
104109
normalization = None
105110
if len(architectures) == 1:
106111
normalization = normalize_single_architecture(
@@ -115,6 +120,12 @@ def package(args):
115120
maximum_deployment_target,
116121
maximum_entries=args.maximum_audit_entries,
117122
)
123+
normalization = (
124+
pre_signature_evidence['normalization']
125+
if normalization is None else normalization
126+
)
127+
normalization['installNames'] = install_names
128+
normalization['summary']['relocatedInstallNames'] = len(install_names)
118129
for item in sorted(
119130
pre_signature_evidence['binaries'],
120131
key=lambda value: len(Path(value['path']).parts), reverse=True):

0 commit comments

Comments
 (0)