From 6a45710bf2224360b463be499167d72d28f8a54f Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Mon, 27 Jan 2025 10:55:50 -0500 Subject: [PATCH] feat: add validator to check that macOS app allows unsigned executable memory (#2806) --- packages/shorebird_cli/lib/src/doctor.dart | 4 +- .../macos_network_entitlement_validator.dart | 58 ++++++- ...os_network_entitlement_validator_test.dart | 161 ++++++------------ 3 files changed, 103 insertions(+), 120 deletions(-) diff --git a/packages/shorebird_cli/lib/src/doctor.dart b/packages/shorebird_cli/lib/src/doctor.dart index 989f6c42..1e544da1 100644 --- a/packages/shorebird_cli/lib/src/doctor.dart +++ b/packages/shorebird_cli/lib/src/doctor.dart @@ -26,7 +26,7 @@ class Doctor { /// Validators that verify shorebird will work on macOS. final List macosCommandValidators = [ - MacosNetworkEntitlementValidator(), + MacosEntitlementsValidator(), ]; /// Validators that verify shorebird will work on Windows. @@ -38,7 +38,7 @@ class Doctor { List generalValidators = [ ShorebirdVersionValidator(), AndroidInternetPermissionValidator(), - MacosNetworkEntitlementValidator(), + MacosEntitlementsValidator(), ShorebirdYamlAssetValidator(), TrackedLockFilesValidator(), ]; diff --git a/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart b/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart index 09467a5c..c3f855eb 100644 --- a/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart +++ b/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart @@ -9,12 +9,18 @@ import 'package:shorebird_cli/src/validators/validators.dart'; /// Checks that the macOS app has the network client entitlement. Without this /// entitlement, the app will not be able to make network requests, and /// Shorebird will not be able to check for patches. -class MacosNetworkEntitlementValidator extends Validator { - /// The plist key for the Outgoing Connections (Client) entitlement, which - /// allows macOS apps to make network requests. +class MacosEntitlementsValidator extends Validator { + /// The entitlements plist key for the Outgoing Connections (Client) + /// entitlement, which allows macOS apps to make network requests. static const networkClientEntitlementKey = 'com.apple.security.network.client'; + /// The entitlements plist key for the Allow Unsigned Executable Memory + /// entitlement, which allows the app to run code not included in the original + /// binary (e.g. patches). + static const allowUnsignedExecutableMemoryKey = + 'com.apple.security.cs.allow-unsigned-executable-memory'; + Directory? get _macosDirectory { final projectRoot = shorebirdEnv.getFlutterProjectRoot(); if (projectRoot == null) { @@ -45,7 +51,7 @@ class MacosNetworkEntitlementValidator extends Validator { } @override - String get description => 'macOS app has Outgoing Connections entitlement'; + String get description => 'macOS app has correct entitlements'; @override bool canRunInCurrentContext() => _macosDirectory?.existsSync() ?? false; @@ -67,18 +73,34 @@ The command you are running must be run within a Flutter app project that suppor ]; } + final issues = []; if (!hasNetworkClientEntitlement(plistFile: _releaseEntitlementsPlist!)) { - return [ + issues.add( ValidationIssue( severity: ValidationIssueSeverity.error, message: '''${_releaseEntitlementsPlist!.path} is missing the Outgoing Connections ($networkClientEntitlementKey) entitlement.''', fix: () => addNetworkEntitlementToPlist(_releaseEntitlementsPlist!), ), - ]; + ); } - return []; + if (!hasAllowUnsignedExecutableMemoryEntitlement( + plistFile: _releaseEntitlementsPlist!, + )) { + issues.add( + ValidationIssue( + severity: ValidationIssueSeverity.error, + message: + '''${_releaseEntitlementsPlist!.path} is missing the Allow Unsigned Executable Memory ($allowUnsignedExecutableMemoryKey) entitlement.''', + fix: () => addAllowUnsignedExecutableMemoryEntitlementToPlist( + _releaseEntitlementsPlist!, + ), + ), + ); + } + + return issues; } /// Whether the given entitlements plist file has the network client @@ -94,4 +116,26 @@ The command you are running must be run within a Flutter app project that suppor plist.properties[networkClientEntitlementKey] = true; entitlementsPlist.writeAsStringSync(plist.toString()); } + + /// Whether the given entitlements plist file has the allow unsigned + /// executable memory entitlement. + @visibleForTesting + static bool hasAllowUnsignedExecutableMemoryEntitlement({ + required File plistFile, + }) { + return Plist(file: plistFile) + .properties[allowUnsignedExecutableMemoryKey] == + true; + } + + /// Adds the allow unsigned executable memory entitlement to the given + /// entitlements plist file. + @visibleForTesting + static void addAllowUnsignedExecutableMemoryEntitlementToPlist( + File entitlementsPlist, + ) { + final plist = Plist(file: entitlementsPlist); + plist.properties[allowUnsignedExecutableMemoryKey] = true; + entitlementsPlist.writeAsStringSync(plist.toString()); + } } diff --git a/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart b/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart index b4ce6f83..094f082a 100644 --- a/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart +++ b/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart @@ -10,8 +10,8 @@ import 'package:test/test.dart'; import '../mocks.dart'; void main() { - group(MacosNetworkEntitlementValidator, () { - const entitlementsPlistWithoutEntitlement = ''' + group(MacosEntitlementsValidator, () { + const entitlementsPlistWithoutEntitlements = ''' @@ -22,13 +22,15 @@ void main() { '''; - const entitlementsPlistWithEntitlement = ''' + const entitlementsPlistWithAllEntitlements = ''' com.apple.security.app-sandbox + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.network.client @@ -37,7 +39,7 @@ void main() { late Directory projectRoot; late ShorebirdEnv shorebirdEnv; - late MacosNetworkEntitlementValidator validator; + late MacosEntitlementsValidator validator; R runWithOverrides(R Function() body) { return runScoped( @@ -70,14 +72,14 @@ void main() { when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(projectRoot); - validator = MacosNetworkEntitlementValidator(); + validator = MacosEntitlementsValidator(); }); group('description', () { test('returns the correct description', () { expect( runWithOverrides(() => validator.description), - 'macOS app has Outgoing Connections entitlement', + 'macOS app has correct entitlements', ); }); }); @@ -130,34 +132,61 @@ void main() { }); group('when release entitlements plist exists', () { - group('when network client entitlement is missing', () { + group('when entitlements are missing', () { setUp(() { - setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement); + setUpProjectRoot( + entitlements: entitlementsPlistWithoutEntitlements, + ); }); - test('returns a validation issue with a fix', () async { + test('returns validation issues with fixes', () async { final validationResults = await runWithOverrides( () => validator.validate(), ); - expect(validationResults, hasLength(1)); - final issue = validationResults[0]; - expect(issue.severity, ValidationIssueSeverity.error); + expect(validationResults, hasLength(2)); + final networkIssue = validationResults[0]; + expect(networkIssue.severity, ValidationIssueSeverity.error); expect( - issue.message, + networkIssue.message, contains( '''is missing the Outgoing Connections (com.apple.security.network.client) entitlement.''', ), ); - expect(issue.fix, isNotNull); + expect(networkIssue.fix, isNotNull); expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + MacosEntitlementsValidator.hasNetworkClientEntitlement( plistFile: releaseEntitlementsFile(), ), isFalse, ); - runWithOverrides(() => issue.fix!()); + runWithOverrides(() => networkIssue.fix!()); expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + MacosEntitlementsValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isTrue, + ); + + final unsignedMemoryIssue = validationResults[1]; + expect(unsignedMemoryIssue.severity, ValidationIssueSeverity.error); + expect( + unsignedMemoryIssue.message, + contains( + '''is missing the Allow Unsigned Executable Memory (com.apple.security.cs.allow-unsigned-executable-memory) entitlement.''', + ), + ); + expect(unsignedMemoryIssue.fix, isNotNull); + expect( + MacosEntitlementsValidator + .hasAllowUnsignedExecutableMemoryEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isFalse, + ); + runWithOverrides(() => unsignedMemoryIssue.fix!()); + expect( + MacosEntitlementsValidator + .hasAllowUnsignedExecutableMemoryEntitlement( plistFile: releaseEntitlementsFile(), ), isTrue, @@ -165,9 +194,11 @@ void main() { }); }); - group('when network client entitlement is present', () { + group('when entitlements are present', () { setUp(() { - setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement); + setUpProjectRoot( + entitlements: entitlementsPlistWithAllEntitlements, + ); }); test('returns an empty list', () async { @@ -176,97 +207,5 @@ void main() { }); }); }); - - group('fix', () { - group('when the network client entitlement is missing', () { - setUp(() { - setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement); - }); - - test('adds the network client entitlement to the entitlements plist', - () { - expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( - plistFile: releaseEntitlementsFile(), - ), - isFalse, - ); - - MacosNetworkEntitlementValidator.addNetworkEntitlementToPlist( - releaseEntitlementsFile(), - ); - - expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( - plistFile: releaseEntitlementsFile(), - ), - isTrue, - ); - }); - }); - - group('when the network client entitlement is present', () { - setUp(() { - setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement); - }); - - test('does not modify the entitlements plist', () { - final plistContents = releaseEntitlementsFile().readAsStringSync(); - expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( - plistFile: releaseEntitlementsFile(), - ), - isTrue, - ); - - MacosNetworkEntitlementValidator.addNetworkEntitlementToPlist( - releaseEntitlementsFile(), - ); - - expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( - plistFile: releaseEntitlementsFile(), - ), - isTrue, - ); - - final updatedPlistContents = - releaseEntitlementsFile().readAsStringSync(); - expect(updatedPlistContents, plistContents); - }); - }); - }); - - group('plistHasNetworkClientEntitlement', () { - group('when entitlement is present', () { - setUp(() { - setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement); - }); - - test('returns true', () { - expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( - plistFile: releaseEntitlementsFile(), - ), - isTrue, - ); - }); - }); - - group('when entitlement is not present', () { - setUp(() { - setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement); - }); - - test('returns false', () { - expect( - MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( - plistFile: releaseEntitlementsFile(), - ), - isFalse, - ); - }); - }); - }); }); }