diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..167f159 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,39 @@ +name: Package build & test + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + build: + name: Build and Test SecurityToolkit scheme using any available iPhone simulator + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set Default Scheme + run: | + default="SecurityToolkit" + echo $default | cat >default + echo Using default scheme: $default + - name: Build + env: + scheme: ${{ 'default' }} + platform: ${{ 'iOS Simulator' }} + run: | + # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) + device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` + if [ $scheme = default ]; then scheme=$(cat default); fi + xcodebuild build-for-testing -scheme "$scheme" -destination "platform=$platform,name=$device" + - name: Test + env: + scheme: ${{ 'default' }} + platform: ${{ 'iOS Simulator' }} + run: | + # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) + device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` + if [ $scheme = default ]; then scheme=$(cat default); fi + xcodebuild test -scheme "$scheme" -destination "platform=$platform,name=$device" diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkit.xcscheme new file mode 100644 index 0000000..320d94f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkit.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkitExample.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkitExample.xcscheme index 8a34f14..dc5c6cc 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkitExample.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SecurityToolkitExample.xcscheme @@ -1,7 +1,7 @@ + version = "2.1"> + + + + + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + + + + + @@ -35,7 +38,7 @@ Swift Package Manager ### SPM `.package(url: "https://github.com/EXXETA/iOS-Security-Toolkit.git", from: -"1.1.1")` +"2.0.0")` ## CocoaPods @@ -64,6 +67,10 @@ Use Async Stream API to get detected threats asynchronously: - `ThreatDetectionCenter.threats: AsyncStream` +## Testing + +To run the available tests locally or in the pipeline use `xcodebuild test -scheme SecurityToolkitTests -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0'` + ## Roadmap Next features to be implemented: diff --git a/SecurityToolkit.podspec b/SecurityToolkit.podspec index ca95c83..dee58bf 100644 --- a/SecurityToolkit.podspec +++ b/SecurityToolkit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'SecurityToolkit' - s.version = '1.1.1' + s.version = '2.0.0' s.summary = 'Simple and easy security threat detector in Swift' s.homepage = 'https://github.com/EXXETA/iOS-Security-Toolkit' s.license = { :type => 'MIT', :file => 'LICENSE.md' } diff --git a/SecurityToolkit.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SecurityToolkit.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6b299ab..3bec300 100644 --- a/SecurityToolkit.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SecurityToolkit.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "bb1fde65ac9d8228a88914af6416b18259fe2171aba8051e9d0785a832c7af82", + "originHash" : "3d50cc595c2a948bdc9828b418ac81066c7ea0247c8f72b21d19749ee60ed4e3", "pins" : [ { "identity" : "r.swift", "kind" : "remoteSourceControl", "location" : "https://github.com/mac-cain13/R.swift.git", "state" : { - "revision" : "384eab88d1a0b98ac96f4819e50a4308ecd5359f", - "version" : "7.5.0" + "revision" : "a9abc6b0afe0fc4a5a71e1d7d2872143dff2d2f1", + "version" : "7.8.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tomlokhorst/XcodeEdit", "state" : { - "revision" : "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", - "version" : "2.9.2" + "revision" : "0e550cdee72844b35431afc3a1e176042be6d0f0", + "version" : "2.13.0" } } ], diff --git a/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.pbxproj b/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.pbxproj index 2556d5a..a4e21d2 100644 --- a/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.pbxproj +++ b/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -12,25 +12,44 @@ 42B973FC2C57C4380040D9BC /* ThreatList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B973FB2C57C4380040D9BC /* ThreatList.swift */; }; 42B973FE2C57C4390040D9BC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 42B973FD2C57C4390040D9BC /* Assets.xcassets */; }; 42C3DCE12C5A877B00973681 /* ThreatRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3DCE02C5A877B00973681 /* ThreatRow.swift */; }; - 42C3DCE32C5A87B200973681 /* ThreatStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3DCE22C5A87B200973681 /* ThreatStatus.swift */; }; + 42C3DCE32C5A87B200973681 /* ThreatOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3DCE22C5A87B200973681 /* ThreatOverview.swift */; }; 42C3DCE52C5A892D00973681 /* Dimens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3DCE42C5A892D00973681 /* Dimens.swift */; }; 42C3DCE82C5CE54B00973681 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 42C3DCE72C5CE54B00973681 /* RswiftLibrary */; }; 42C3DCED2C5CE6F500973681 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 42C3DCEB2C5CE6F500973681 /* Localizable.strings */; }; 42C3DCF52C5CEFE200973681 /* SystemImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3DCF42C5CEFE200973681 /* SystemImage.swift */; }; + 881894A52E71B35A00989BA6 /* SecurityToolkitExample.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 881894A42E71B35A00989BA6 /* SecurityToolkitExample.xctestplan */; }; + 882CEB5F2D536FF0005D7171 /* ThreatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 882CEB5D2D536FE8005D7171 /* ThreatViewModel.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8818949E2E71B2AA00989BA6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 42B973EE2C57C4380040D9BC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 42B973F52C57C4380040D9BC; + remoteInfo = SecurityToolkitExample; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 42B973F62C57C4380040D9BC /* SecurityToolkitExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecurityToolkitExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 42B973F92C57C4380040D9BC /* SecurityToolkitExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityToolkitExampleApp.swift; sourceTree = ""; }; 42B973FB2C57C4380040D9BC /* ThreatList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreatList.swift; sourceTree = ""; }; 42B973FD2C57C4390040D9BC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 42C3DCE02C5A877B00973681 /* ThreatRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreatRow.swift; sourceTree = ""; }; - 42C3DCE22C5A87B200973681 /* ThreatStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreatStatus.swift; sourceTree = ""; }; + 42C3DCE22C5A87B200973681 /* ThreatOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreatOverview.swift; sourceTree = ""; }; 42C3DCE42C5A892D00973681 /* Dimens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dimens.swift; sourceTree = ""; }; 42C3DCEC2C5CE6F500973681 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 42C3DCF42C5CEFE200973681 /* SystemImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImage.swift; sourceTree = ""; }; + 8818949A2E71B2AA00989BA6 /* SecurityToolkitExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SecurityToolkitExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 881894A42E71B35A00989BA6 /* SecurityToolkitExample.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SecurityToolkitExample.xctestplan; sourceTree = ""; }; + 882CEB5D2D536FE8005D7171 /* ThreatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreatViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 8818949B2E71B2AA00989BA6 /* SecurityToolkitExampleTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SecurityToolkitExampleTests; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 42B973F32C57C4380040D9BC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -41,6 +60,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 881894972E71B2AA00989BA6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -54,7 +80,9 @@ 42B973ED2C57C4380040D9BC = { isa = PBXGroup; children = ( + 881894A42E71B35A00989BA6 /* SecurityToolkitExample.xctestplan */, 42B973F82C57C4380040D9BC /* SecurityToolkitExample */, + 8818949B2E71B2AA00989BA6 /* SecurityToolkitExampleTests */, 42B973F72C57C4380040D9BC /* Products */, 424907A82C57C6A000BC1E7A /* Frameworks */, ); @@ -64,6 +92,7 @@ isa = PBXGroup; children = ( 42B973F62C57C4380040D9BC /* SecurityToolkitExample.app */, + 8818949A2E71B2AA00989BA6 /* SecurityToolkitExampleTests.xctest */, ); name = Products; sourceTree = ""; @@ -89,10 +118,11 @@ 42C3DCEA2C5CE64300973681 /* Sources */ = { isa = PBXGroup; children = ( + 882CEB5D2D536FE8005D7171 /* ThreatViewModel.swift */, 42B973F92C57C4380040D9BC /* SecurityToolkitExampleApp.swift */, 42B973FB2C57C4380040D9BC /* ThreatList.swift */, 42C3DCE02C5A877B00973681 /* ThreatRow.swift */, - 42C3DCE22C5A87B200973681 /* ThreatStatus.swift */, + 42C3DCE22C5A87B200973681 /* ThreatOverview.swift */, 42C3DCE42C5A892D00973681 /* Dimens.swift */, 42C3DCF42C5CEFE200973681 /* SystemImage.swift */, ); @@ -124,6 +154,29 @@ productReference = 42B973F62C57C4380040D9BC /* SecurityToolkitExample.app */; productType = "com.apple.product-type.application"; }; + 881894992E71B2AA00989BA6 /* SecurityToolkitExampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 881894A02E71B2AA00989BA6 /* Build configuration list for PBXNativeTarget "SecurityToolkitExampleTests" */; + buildPhases = ( + 881894962E71B2AA00989BA6 /* Sources */, + 881894972E71B2AA00989BA6 /* Frameworks */, + 881894982E71B2AA00989BA6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8818949F2E71B2AA00989BA6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8818949B2E71B2AA00989BA6 /* SecurityToolkitExampleTests */, + ); + name = SecurityToolkitExampleTests; + packageProductDependencies = ( + ); + productName = SecurityToolkitExampleTests; + productReference = 8818949A2E71B2AA00989BA6 /* SecurityToolkitExampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -131,12 +184,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1540; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1540; TargetAttributes = { 42B973F52C57C4380040D9BC = { CreatedOnToolsVersion = 15.4; }; + 881894992E71B2AA00989BA6 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = 42B973F52C57C4380040D9BC; + }; }; }; buildConfigurationList = 42B973F12C57C4380040D9BC /* Build configuration list for PBXProject "SecurityToolkitExample" */; @@ -155,6 +212,7 @@ projectRoot = ""; targets = ( 42B973F52C57C4380040D9BC /* SecurityToolkitExample */, + 881894992E71B2AA00989BA6 /* SecurityToolkitExampleTests */, ); }; /* End PBXProject section */ @@ -169,6 +227,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 881894982E71B2AA00989BA6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 881894A52E71B35A00989BA6 /* SecurityToolkitExample.xctestplan in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -178,10 +244,18 @@ files = ( 42B973FC2C57C4380040D9BC /* ThreatList.swift in Sources */, 42C3DCF52C5CEFE200973681 /* SystemImage.swift in Sources */, - 42C3DCE32C5A87B200973681 /* ThreatStatus.swift in Sources */, + 42C3DCE32C5A87B200973681 /* ThreatOverview.swift in Sources */, 42C3DCE12C5A877B00973681 /* ThreatRow.swift in Sources */, 42C3DCE52C5A892D00973681 /* Dimens.swift in Sources */, 42B973FA2C57C4380040D9BC /* SecurityToolkitExampleApp.swift in Sources */, + 882CEB5F2D536FF0005D7171 /* ThreatViewModel.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 881894962E71B2AA00989BA6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -192,6 +266,11 @@ isa = PBXTargetDependency; productRef = 42C3DCF02C5CEAD000973681 /* RswiftGenerateInternalResources */; }; + 8818949F2E71B2AA00989BA6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 42B973F52C57C4380040D9BC /* SecurityToolkitExample */; + targetProxy = 8818949E2E71B2AA00989BA6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -344,7 +423,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.5; @@ -396,6 +475,45 @@ }; name = Release; }; + 881894A12E71B2AA00989BA6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.exxeta.SecurityToolkitExampleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SecurityToolkitExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SecurityToolkitExample"; + }; + name = Debug; + }; + 881894A22E71B2AA00989BA6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.exxeta.SecurityToolkitExampleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SecurityToolkitExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SecurityToolkitExample"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -417,6 +535,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 881894A02E71B2AA00989BA6 /* Build configuration list for PBXNativeTarget "SecurityToolkitExampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 881894A12E71B2AA00989BA6 /* Debug */, + 881894A22E71B2AA00989BA6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -425,7 +552,7 @@ repositoryURL = "https://github.com/mac-cain13/R.swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.5.0; + minimumVersion = 7.8.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8d583b..d65e458 100644 --- a/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SecurityToolkitExample/SecurityToolkitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mac-cain13/R.swift.git", "state" : { - "revision" : "384eab88d1a0b98ac96f4819e50a4308ecd5359f", - "version" : "7.5.0" + "revision" : "a9abc6b0afe0fc4a5a71e1d7d2872143dff2d2f1", + "version" : "7.8.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tomlokhorst/XcodeEdit", "state" : { - "revision" : "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", - "version" : "2.9.2" + "revision" : "0e550cdee72844b35431afc3a1e176042be6d0f0", + "version" : "2.13.0" } } ], diff --git a/SecurityToolkitExample/SecurityToolkitExample.xctestplan b/SecurityToolkitExample/SecurityToolkitExample.xctestplan new file mode 100644 index 0000000..64b5775 --- /dev/null +++ b/SecurityToolkitExample/SecurityToolkitExample.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "2A48008B-945B-4FC4-B949-3BD5FEAAF6CF", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:SecurityToolkitExample\/SecurityToolkitExample.xcodeproj", + "identifier" : "881894992E71B2AA00989BA6", + "name" : "SecurityToolkitExampleTests" + } + } + ], + "version" : 1 +} diff --git a/SecurityToolkitExample/SecurityToolkitExample/Resources/en.lproj/Localizable.strings b/SecurityToolkitExample/SecurityToolkitExample/Resources/en.lproj/Localizable.strings index 7fb6750..4920edf 100644 --- a/SecurityToolkitExample/SecurityToolkitExample/Resources/en.lproj/Localizable.strings +++ b/SecurityToolkitExample/SecurityToolkitExample/Resources/en.lproj/Localizable.strings @@ -17,5 +17,7 @@ "threat.list.description" = "Here is a list of the threats that could put you at risk"; // MARK: Threat Row +"threat.row.state.initial" = "INITIAL"; "threat.row.state.detected" = "DETECTED"; "threat.row.state.safe" = "SAFE"; +"threat.row.state.exception" = "EXCEPTION"; diff --git a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatList.swift b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatList.swift index 2ed9457..f47ed0c 100644 --- a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatList.swift +++ b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatList.swift @@ -1,11 +1,14 @@ import SwiftUI +import SecurityToolkit struct ThreatList: View { + @ObservedObject private var vm = ThreatViewModel.shared + var body: some View { List { Section { - ForEach(ThreatStatus.threats, id: \.self) { + ForEach(vm.threatOverview, id: \.self) { ThreatRow(threat: $0) } } header: { diff --git a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatOverview.swift b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatOverview.swift new file mode 100644 index 0000000..264d576 --- /dev/null +++ b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatOverview.swift @@ -0,0 +1,48 @@ +import Foundation +import SecurityToolkit + +struct ThreatOverview: Hashable { + let title: String + let description: String + let status: ThreatStatus + var isOk: Bool { + get { status == .notChecked || status == .notPresent } + } + + static func threats(threatsReport: ThreatReport) -> [ThreatOverview] { + [ + ThreatOverview( + title: R.string.localizable.threatJailbreakTitle(), + description: R.string.localizable.threatJailbreakDescription(), + status: threatsReport.rootPrivileges + ), + ThreatOverview( + title: R.string.localizable.threatHooksTitle(), + description: R.string.localizable.threatHooksDescription(), + status: threatsReport.hooks + ), + ThreatOverview( + title: R.string.localizable.threatSimulatorTitle(), + description: R.string.localizable.threatSimulatorDescription(), + status: threatsReport.simulator + ), + ThreatOverview( + title: R.string.localizable.threatDebuggerTitle(), + description: R.string.localizable.threatDebuggerDescription(), + status: threatsReport.debugger + ), + ThreatOverview( + title: R.string.localizable.threatPasscodeUnprotectedDeviceTitle(), + description: R.string.localizable.threatPasscodeUnprotectedDeviceDescription(), + status: threatsReport.devicePasscode + ), + ThreatOverview( + title: R.string.localizable.threatHardwareTitle(), + description: R.string.localizable.threatHardwareDescription(), + status: threatsReport.hardwareCryptography + ), + ] + } +} + + diff --git a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatRow.swift b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatRow.swift index d643ded..2edaf19 100644 --- a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatRow.swift +++ b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatRow.swift @@ -2,21 +2,32 @@ import SwiftUI struct ThreatRow: View { - let threat: ThreatStatus + let threat: ThreatOverview + + var threatText: String { + get { + switch threat.status { + case .notChecked: return R.string.localizable.threatRowStateInitial() + case .notPresent: return R.string.localizable.threatRowStateSafe() + case .present: return R.string.localizable.threatRowStateDetected() + case .exception: return R.string.localizable.threatRowStateException() + } + } + } var body: some View { VStack(alignment: .leading) { HStack { Text(threat.title) Spacer() - Text(threat.isDetected ? R.string.localizable.threatRowStateDetected() : R.string.localizable.threatRowStateSafe()) + Text(threatText) .font(.caption2) .foregroundColor(.white) .padding(.horizontal, Dimens.unit8) .padding(.vertical, Dimens.unit2) .background { RoundedRectangle(cornerRadius: Dimens.unit4) - .fill(threat.isDetected ? .red : .green) + .fill(threat.isOk ? .green : .red) } } Text(threat.description) diff --git a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatStatus.swift b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatStatus.swift deleted file mode 100644 index 07e88b0..0000000 --- a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatStatus.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import SecurityToolkit - -struct ThreatStatus: Hashable { - let title: String - let description: String - let isDetected: Bool - - static let threats = [ - ThreatStatus( - title: R.string.localizable.threatJailbreakTitle(), - description: R.string.localizable.threatJailbreakDescription(), - isDetected: ThreatDetectionCenter.areRootPrivilegesDetected - ), - ThreatStatus( - title: R.string.localizable.threatHooksTitle(), - description: R.string.localizable.threatHooksDescription(), - isDetected: ThreatDetectionCenter.areHooksDetected - ), - ThreatStatus( - title: R.string.localizable.threatSimulatorTitle(), - description: R.string.localizable.threatSimulatorDescription(), - isDetected: ThreatDetectionCenter.isSimulatorDetected - ), - ThreatStatus( - title: R.string.localizable.threatDebuggerTitle(), - description: R.string.localizable.threatDebuggerDescription(), - isDetected: ThreatDetectionCenter.isDebuggerDetected ?? false - ), - ThreatStatus( - title: R.string.localizable.threatPasscodeUnprotectedDeviceTitle(), - description: R.string.localizable.threatPasscodeUnprotectedDeviceDescription(), - isDetected: ThreatDetectionCenter.isDeviceWithoutPasscodeDetected - ), - ThreatStatus( - title: R.string.localizable.threatHardwareTitle(), - description: R.string.localizable.threatHardwareDescription(), - isDetected: ThreatDetectionCenter.isHardwareProtectionUnavailable - ), - ] -} - - diff --git a/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatViewModel.swift b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatViewModel.swift new file mode 100644 index 0000000..ac32d5d --- /dev/null +++ b/SecurityToolkitExample/SecurityToolkitExample/Sources/ThreatViewModel.swift @@ -0,0 +1,23 @@ +// +// ThreatViewModel.swift +// SecurityToolkitExample +// +// Created by Dobanda, Denis on 05.02.25. +// +import Foundation +import SecurityToolkit +import Combine + +class ThreatViewModel: ObservableObject { + static let shared: ThreatViewModel = ThreatViewModel() + + @Published var threatOverview = [ThreatOverview]() + + private init() { + ThreatDetectionCenter + .threatReports + .map(ThreatOverview.threats(threatsReport:)) + .receive(on: DispatchQueue.main) + .assign(to: &$threatOverview) + } +} diff --git a/SecurityToolkitExample/SecurityToolkitExampleTests/SecurityToolkitExampleTests.swift b/SecurityToolkitExample/SecurityToolkitExampleTests/SecurityToolkitExampleTests.swift new file mode 100644 index 0000000..ca45ce8 --- /dev/null +++ b/SecurityToolkitExample/SecurityToolkitExampleTests/SecurityToolkitExampleTests.swift @@ -0,0 +1,111 @@ +// +// SecurityToolkitTests.swift +// SecurityToolkitTests +// +// Created by Dobanda, Denis on 10.09.25. +// + +import Testing +import Combine +import SecurityToolkit + +struct SecurityToolkitTests { + + @Test func testDetectedThreadsForRootPrivileges() async throws { + var cancellable: Set = [] + await confirmation { confirmation in + ThreatDetectionCenter.threatReports + .filter{ $0.rootPrivileges != .notChecked } + .sink { report in + #expect(report.rootPrivileges == .notPresent) + confirmation() + } + .store(in: &cancellable) + } + } + + @Test func testDetectedThreadsForHooks() async throws { + var cancellable: Set = [] + await confirmation { confirmation in + ThreatDetectionCenter.threatReports + .filter{ $0.hooks != .notChecked } + .sink { report in + #expect(report.hooks == .notPresent) + confirmation() + } + .store(in: &cancellable) + } + } + + @Test func testDetectedThreadsForSimulator() async throws { + var cancellable: Set = [] + await confirmation { confirmation in + ThreatDetectionCenter.threatReports + .filter{ $0.simulator != .notChecked } + .sink { report in + #expect(report.simulator == .present) + confirmation() + } + .store(in: &cancellable) + } + } + + @Test func testDetectedThreadsForDebugger() async throws { + var cancellable: Set = [] + await confirmation { confirmation in + ThreatDetectionCenter.threatReports + .filter{ $0.debugger != .notChecked } + .sink { report in + #expect(report.debugger == .present) + confirmation() + } + .store(in: &cancellable) + } + } + + @Test func testDetectedThreadsForDevicePasscode() async throws { + var cancellable: Set = [] + await confirmation { confirmation in + ThreatDetectionCenter.threatReports + .filter{ $0.devicePasscode != .notChecked } + .sink { report in + #expect(report.devicePasscode == .notPresent) + confirmation() + } + .store(in: &cancellable) + } + } + + @Test func testDetectedThreadsForHardwareCryptography() async throws { + var cancellable: Set = [] + await confirmation { confirmation in + ThreatDetectionCenter.threatReports + .filter{ $0.hardwareCryptography != .notChecked } + .sink { report in + #expect(report.hardwareCryptography == ThreatStatus.exception(ThreatDetectionException.checkNotPossible("Unexpected LAError: Biometry is not enrolled."))) + confirmation() + } + .store(in: &cancellable) + } + } + + + @Test func testConcurentDetectedThreads() async throws { + var cancellable: Set = [] + await confirmation { c in + ThreatDetectionCenter.threatReports + .sink { _ in c()} + .store(in: &cancellable) + } + + await confirmation { c in + ThreatDetectionCenter.threatReports + .sink { report in + #expect(report.rootPrivileges == .notPresent) + c() + } + .store(in: &cancellable) + } + } + +} diff --git a/Sources/ThreatDetectionCenter.swift b/Sources/ThreatDetectionCenter.swift index 499c7f8..fac4380 100644 --- a/Sources/ThreatDetectionCenter.swift +++ b/Sources/ThreatDetectionCenter.swift @@ -1,27 +1,112 @@ import Foundation +import Combine public final class ThreatDetectionCenter { - + + private init() {} + + /// **Seconds** to wait for cycle recheck threads before running check again + /// > Should be positive and greater than 0, otherwise value is left unchanged + static public var threatReportsUpdateInterval: Int { + get { return _threatReportsUpdateInterval } + set { + if (newValue > 0) { + _threatReportsUpdateInterval = newValue + } + } + } + + + // MARK: - Async Threat Detection + + // Private publisher for sending temperature updates + static private let reportPublisher = CurrentValueSubject(ThreatReport()) + + static private var task: Task<(), Never>? + static private var internalTasks: [Task<(), Never>] = [] + static private var _threatReportsUpdateInterval = 10 + + /// Use this API to get ThreatReports + /// + /// > First access will start all Tasks and cache the result. + /// Next calls will not start any additional Tasks. Original result + /// will be cached and can be reused + static public var threatReports: AnyPublisher { + task = task ?? Task { + internalTasks.append( + Task { + repeat { + let status = JailbreakDetector.threatDetected() + reportPublisher.update { $0.copy(rootPrivileges: status) } + await insertDelay() + } while !Task.isCancelled + } + ) + internalTasks.append( + Task { + repeat { + let status = HooksDetector.threatDetected() + reportPublisher.update { $0.copy(hooks: status) } + await insertDelay() + } while !Task.isCancelled + } + ) + internalTasks.append( + Task { + let status = SimulatorDetector.threatDetected() + reportPublisher.update { $0.copy(simulator: status) } + } + ) + internalTasks.append( + Task { + repeat { + let status = DebuggerDetector.threatDetected() + reportPublisher.update { $0.copy(debugger: status) } + await insertDelay() + } while !Task.isCancelled + } + ) + internalTasks.append( + Task { + repeat { + let status = DevicePasscodeDetector.threatDetected() + reportPublisher.update { $0.copy(devicePasscode: status) } + await insertDelay() + } while !Task.isCancelled + } + ) + internalTasks.append( + Task { + let status = HardwareSecurityDetector.threatDetected() + reportPublisher.update { $0.copy(hardwareCryptography: status) } + } + ) + } + return reportPublisher.eraseToAnyPublisher() + } + + public static func close() { + internalTasks.forEach { $0.cancel() } + task?.cancel() + internalTasks.removeAll() + task = nil + } + + // MARK: - Sync API + /// Will check if jailbreak is present /// - /// - Returns: - /// `true`, if device is / was jailbroken; - /// `false` otherwise - /// /// More about jailbreak: https://wikipedia.org/wiki/Jailbreak_%28iOS%29 /// /// > Should also detect jailbreak, even if the device is in a "safe" mode or /// jailbreak mode is not active / was not properly removed - public static var areRootPrivilegesDetected: Bool { - JailbreakDetection.threatDetected() + @available(*, deprecated, message: "Migrate to use new threatReports async API") + public static var rootPrivilegesStatus: ThreatStatus { + JailbreakDetector.threatDetected() } - + /// Will check for an injection tool like Frida /// - /// - Returns: - /// `true`, if dynamic hooks are loaded at the time; - /// `false` otherwise - /// /// More: https://fingerprint.com/blog/exploring-frida-dynamic-instrumentation-tool-kit/ /// /// > By the nature of dynamic hooks, this checks should be made on a regular @@ -30,45 +115,34 @@ public final class ThreatDetectionCenter { /// /// > Important: with a sufficient reverse engineering skills, this check can /// be disabled. Use always in combination with another threats detections. - public static var areHooksDetected: Bool { - HooksDetection.threatDetected() + @available(*, deprecated, message: "Migrate to use new threatReports async API") + public static var hooksStatus: ThreatStatus { + HooksDetector.threatDetected() } - + /// Will check, if the app runs in a emulated / simulated environment - /// - /// - Returns: - /// `true`, if simulator environment is detected; - /// `false` otherwise - public static var isSimulatorDetected: Bool { - SimulatorDetection.threatDetected() + @available(*, deprecated, message: "Migrate to use new threatReports async API") + public static var simulatorStatus: ThreatStatus { + SimulatorDetector.threatDetected() } /// Will check, if the application is being traced by a debugger. /// - /// - Returns: - /// `true`, if a debugger is detected; - /// `false`, if no debugger is detected; - /// `nil`, if the detection process did not produce a definitive result. - /// This could happen due to system limitations, lack of required - /// permissions, or other undefined conditions. - /// /// A debugger is a tool that allows developers to inspect and modify the /// execution of a program in real-time, potentially exposing sensitive data /// or allowing unauthorized control. /// /// > Please note that Apple itself may require a debugger for the app review /// process. - public static var isDebuggerDetected: Bool? { - DebuggerDetection.threatDetected() + @available(*, deprecated, message: "Migrate to use new threatReports async API") + public static var debuggerStatus: ThreatStatus { + DebuggerDetector.threatDetected() } - + /// Will check, if current device is protected with at least a passcode - /// - /// - Returns: - /// `true`, if device is unprotected; - /// `false`, if device is protected with at least a passcode - public static var isDeviceWithoutPasscodeDetected: Bool { - DevicePasscodeDetection.threatDetected() + @available(*, deprecated, message: "Migrate to use new threatReports async API") + public static var devicePasscodeStatus: ThreatStatus { + DevicePasscodeDetector.threatDetected() } /// Will check, if current device has hardware protection layer @@ -78,61 +152,37 @@ public final class ThreatDetectionCenter { /// /// More: https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave /// - /// - Returns: - /// `true`, if device has no hardware protection; - /// `false` otherwise - /// /// > Should be evaluated on a real device. Should only be used as an /// indicator, if current device is capable of hardware protection. Does not /// automatically mean, that encryption operations (keys, certificates, /// keychain) are always backed by hardware. You should make sure, such /// operations are implemented correctly with hardware layer - public static var isHardwareProtectionUnavailable: Bool { - HardwareSecurityDetection.threatDetected() + @available(*, deprecated, message: "Migrate to use new threatReports async API") + public static var hardwareCryptographyStatus: ThreatStatus { + HardwareSecurityDetector.threatDetected() } - - // MARK: - Async Threat Detection - - /// Defines all possible threats, that can be reported via the stream - public enum Threat: String { - case rootPrivileges - case hooks - case simulator - case debugger - case deviceWithoutPasscode - case hardwareProtectionUnavailable + // MARK: - Private API + + static private func insertDelay() async { + try? await Task.sleep(nanoseconds: UInt64(_threatReportsUpdateInterval) * NSEC_PER_SEC) } - - /// Stream that contains possible threats that could be detected - public static var threats: AsyncStream { - AsyncStream { continuation in - - if JailbreakDetection.threatDetected() { - continuation.yield(.rootPrivileges) - } - - if HooksDetection.threatDetected() { - continuation.yield(.hooks) - } - - if SimulatorDetection.threatDetected() { - continuation.yield(.simulator) - } - - if DebuggerDetection.threatDetected() ?? false { - continuation.yield(.debugger) - } +} - if DevicePasscodeDetection.threatDetected() { - continuation.yield(.deviceWithoutPasscode) +fileprivate extension CurrentValueSubject where Output: Equatable { + /// Use this function to update a value in the publisher atomically + func update(_ callback: (Output) -> Output) { + while true { + let value = self.value + let newValue = callback(value) + if (Task.isCancelled) { + return + } else if value == newValue { + return + } else if self.value == value { + self.value = newValue + return } - - if HardwareSecurityDetection.threatDetected() { - continuation.yield(.hardwareProtectionUnavailable) - } - - continuation.finish() } } } diff --git a/Sources/ThreatReport.swift b/Sources/ThreatReport.swift new file mode 100644 index 0000000..d76cca3 --- /dev/null +++ b/Sources/ThreatReport.swift @@ -0,0 +1,72 @@ +import Foundation + +/// An object describing latest detection status of every +/// threat detectors +public struct ThreatReport: Equatable, Hashable { + public let rootPrivileges: ThreatStatus + public let hooks: ThreatStatus + public let simulator: ThreatStatus + public let debugger: ThreatStatus + public let devicePasscode: ThreatStatus + public let hardwareCryptography: ThreatStatus + + public init( + rootPrivileges: ThreatStatus = .notChecked, + hooks: ThreatStatus = .notChecked, + simulator: ThreatStatus = .notChecked, + debugger: ThreatStatus = .notChecked, + devicePasscode: ThreatStatus = .notChecked, + hardwareCryptography: ThreatStatus = .notChecked + ) { + self.rootPrivileges = rootPrivileges + self.hooks = hooks + self.simulator = simulator + self.debugger = debugger + self.devicePasscode = devicePasscode + self.hardwareCryptography = hardwareCryptography + } + + func copy( + rootPrivileges: ThreatStatus? = nil, + hooks: ThreatStatus? = nil, + simulator: ThreatStatus? = nil, + debugger: ThreatStatus? = nil, + devicePasscode: ThreatStatus? = nil, + hardwareCryptography: ThreatStatus? = nil + ) -> ThreatReport { + return ThreatReport( + rootPrivileges: rootPrivileges ?? self.rootPrivileges, + hooks: hooks ?? self.hooks, + simulator: simulator ?? self.simulator, + debugger: debugger ?? self.debugger, + devicePasscode: devicePasscode ?? self.devicePasscode, + hardwareCryptography: hardwareCryptography ?? self.hardwareCryptography + ) + } +} + +public enum ThreatStatus: Equatable, Hashable { + case notChecked + case notPresent + case present + case exception(ThreatDetectionException) + + public static func ==(lhs: ThreatStatus, rhs: ThreatStatus) -> Bool { + switch (lhs, rhs) { + case (.notChecked, .notChecked): + return true + case (.notPresent, .notPresent): + return true + case (.present, .present): + return true + case (.exception(let s1), .exception(let s2)): + return s1 == s2 + default: + return false + } + } +} + +public enum ThreatDetectionException: Error, Equatable, Hashable { + case checkNotPossible(String) +} diff --git a/Sources/internal/DebuggerDetection.swift b/Sources/internal/DebuggerDetection.swift deleted file mode 100644 index 3cafcd2..0000000 --- a/Sources/internal/DebuggerDetection.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -// MARK: - Internal -internal final class DebuggerDetection { - - static func threatDetected() -> Bool? { - hasTracerFlagSet() - } -} - -// MARK: - Private -fileprivate extension DebuggerDetection { - - /// Check P_TRACED flag from Darwin Kernel - /// if the process is traced - private static func hasTracerFlagSet() -> Bool? { - var info = kinfo_proc() - // Kernel info, process info, specific process by PID, get current process ID - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] - var size = MemoryLayout.stride(ofValue: info) - - let unixStatusCode = sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) - - return unixStatusCode == 0 ? (info.kp_proc.p_flag & P_TRACED) != 0 : nil - } -} diff --git a/Sources/internal/DebuggerDetector.swift b/Sources/internal/DebuggerDetector.swift new file mode 100644 index 0000000..ae67211 --- /dev/null +++ b/Sources/internal/DebuggerDetector.swift @@ -0,0 +1,37 @@ +import Foundation + +// MARK: - Internal +internal final class DebuggerDetector { + + static func threatDetected() -> ThreatStatus { + do { + let check = try hasTracerFlagSet() + return check ? .present : .notPresent + } catch let e { + let ex = e as? ThreatDetectionException + ?? ThreatDetectionException.checkNotPossible(e.localizedDescription) + return .exception(ex) + } + } +} + +// MARK: - Private +fileprivate extension DebuggerDetector { + + /// Check P_TRACED flag from Darwin Kernel + /// if the process is traced + private static func hasTracerFlagSet() throws -> Bool { + var info = kinfo_proc() + // Kernel info, process info, specific process by PID, get current process ID + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var size = MemoryLayout.stride(ofValue: info) + + let unixStatusCode = sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) + + if unixStatusCode != 0 { + throw ThreatDetectionException.checkNotPossible("Unexpected unix status code") + } + + return (info.kp_proc.p_flag & P_TRACED) != 0 + } +} diff --git a/Sources/internal/DevicePasscodeDetection.swift b/Sources/internal/DevicePasscodeDetection.swift deleted file mode 100644 index ca796bd..0000000 --- a/Sources/internal/DevicePasscodeDetection.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import LocalAuthentication - -// MARK: - Internal -internal final class DevicePasscodeDetection { - - static func threatDetected() -> Bool { - !hasDevicePasscode() - } -} - -// MARK: - Private -fileprivate extension DevicePasscodeDetection { - - /// Will check if the user can perform authentication with a biometrics or - /// a passcode - private static func hasDevicePasscode() -> Bool { - var error: NSError? - let result = LAContext().canEvaluatePolicy( - .deviceOwnerAuthentication, - error: &error - ) - if result { - return true - } else if let error { - return error.code != LAError.passcodeNotSet.rawValue - } else { - return false - } - } -} diff --git a/Sources/internal/DevicePasscodeDetector.swift b/Sources/internal/DevicePasscodeDetector.swift new file mode 100644 index 0000000..3482f1e --- /dev/null +++ b/Sources/internal/DevicePasscodeDetector.swift @@ -0,0 +1,39 @@ +import Foundation +import LocalAuthentication + +// MARK: - Internal +internal final class DevicePasscodeDetector { + + static func threatDetected() -> ThreatStatus { + hasDevicePasscode() + } +} + +// MARK: - Private +fileprivate extension DevicePasscodeDetector { + + /// Will check if the user can perform authentication with a biometrics or + /// a passcode + private static func hasDevicePasscode() -> ThreatStatus { + var error: NSError? + let result = LAContext().canEvaluatePolicy( + .deviceOwnerAuthentication, + error: &error + ) + if result { + return .notPresent + } else if let error { + if error.code != LAError.passcodeNotSet.rawValue { + return .exception( + ThreatDetectionException.checkNotPossible( + "Unexpected LAError: \(error.localizedDescription)" + ) + ) + } else { + return .present + } + } else { + return .present + } + } +} diff --git a/Sources/internal/HardwareSecurityDetection.swift b/Sources/internal/HardwareSecurityDetector.swift similarity index 52% rename from Sources/internal/HardwareSecurityDetection.swift rename to Sources/internal/HardwareSecurityDetector.swift index 2094417..9ba520e 100644 --- a/Sources/internal/HardwareSecurityDetection.swift +++ b/Sources/internal/HardwareSecurityDetector.swift @@ -2,18 +2,18 @@ import Foundation import LocalAuthentication // MARK: - Internal -internal final class HardwareSecurityDetection { +internal final class HardwareSecurityDetector { - static func threatDetected() -> Bool { - !isSecureEnclaveAvailable() + static func threatDetected() -> ThreatStatus { + isSecureEnclaveAvailable() } } // MARK: - Private -fileprivate extension HardwareSecurityDetection { +fileprivate extension HardwareSecurityDetector { /// See https://stackoverflow.com/a/49318485/7484013 - private static func isSecureEnclaveAvailable() -> Bool { + private static func isSecureEnclaveAvailable() -> ThreatStatus { var error: NSError? /// Policies can have certain requirements which, when not satisfied, @@ -25,11 +25,19 @@ fileprivate extension HardwareSecurityDetection { error: &error ) if result { - return true + return .notPresent } else if let error { - return error.code != LAError.biometryNotAvailable.rawValue + if error.code != LAError.biometryNotAvailable.rawValue { + return .exception( + ThreatDetectionException.checkNotPossible( + "Unexpected LAError: \(error.localizedDescription)" + ) + ) + } else { + return .present + } } else { - return false + return .present } } } diff --git a/Sources/internal/HooksDetection.swift b/Sources/internal/HooksDetector.swift similarity index 88% rename from Sources/internal/HooksDetection.swift rename to Sources/internal/HooksDetector.swift index bb4c394..157a21d 100644 --- a/Sources/internal/HooksDetection.swift +++ b/Sources/internal/HooksDetector.swift @@ -2,15 +2,16 @@ import Foundation import MachO // MARK: - Internal -internal final class HooksDetection { +internal final class HooksDetector { - static func threatDetected() -> Bool { - hasDynamicLibrariesLoaded() || hasSuspiciousFiles() || hasOpenPorts() + static func threatDetected() -> ThreatStatus { + let check = hasDynamicLibrariesLoaded() || hasSuspiciousFiles() || hasOpenPorts() + return check ? .present : .notPresent } } // MARK: - Private -fileprivate extension HooksDetection { +fileprivate extension HooksDetector { /// Check has loaded dynamic libraries private static func hasDynamicLibrariesLoaded() -> Bool { diff --git a/Sources/internal/JailbreakDetection.swift b/Sources/internal/JailbreakDetector.swift similarity index 91% rename from Sources/internal/JailbreakDetection.swift rename to Sources/internal/JailbreakDetector.swift index 97a18d5..425b1a9 100644 --- a/Sources/internal/JailbreakDetection.swift +++ b/Sources/internal/JailbreakDetector.swift @@ -2,15 +2,16 @@ import Foundation import UIKit // MARK: - Internal -internal final class JailbreakDetection { +internal final class JailbreakDetector { - static func threatDetected() -> Bool { - hasSuspiciousFiles() || hasUnexpectedFilePermissions() || canOpenSuspiciousLinks() + static func threatDetected() -> ThreatStatus { + let check = hasSuspiciousFiles() || hasUnexpectedFilePermissions() || canOpenSuspiciousLinks() + return check ? .present : .notPresent } } // MARK: - Private -fileprivate extension JailbreakDetection { +fileprivate extension JailbreakDetector { /// Check for suspicious files static func hasSuspiciousFiles() -> Bool { diff --git a/Sources/internal/SimulatorDetection.swift b/Sources/internal/SimulatorDetector.swift similarity index 64% rename from Sources/internal/SimulatorDetection.swift rename to Sources/internal/SimulatorDetector.swift index 951bd4e..a9701dc 100644 --- a/Sources/internal/SimulatorDetection.swift +++ b/Sources/internal/SimulatorDetector.swift @@ -1,15 +1,16 @@ import Foundation // MARK: - Internal -internal final class SimulatorDetection { +internal final class SimulatorDetector { - static func threatDetected() -> Bool { - runsInSimulator() + static func threatDetected() -> ThreatStatus { + let check = runsInSimulator() + return check ? .present : .notPresent } } // MARK: - Private -fileprivate extension SimulatorDetection { +fileprivate extension SimulatorDetector { /// Check if the app is running in a simulator static func runsInSimulator() -> Bool { diff --git a/Tests/SecurityToolkitTests/SecurityToolkitTests.swift b/Tests/SecurityToolkitTests/SecurityToolkitTests.swift new file mode 100644 index 0000000..2104976 --- /dev/null +++ b/Tests/SecurityToolkitTests/SecurityToolkitTests.swift @@ -0,0 +1,130 @@ +// +// SecurityToolkitTests.swift +// SecurityToolkitTests +// +// Created by Dobanda, Denis on 10.09.25. +// + +import Foundation +import Testing +import Combine +@testable import SecurityToolkit + +@Suite(.serialized) struct SecurityToolkitTests { + + @Test func testNoDetectedThreadsAfterCancel() async throws { + var cancellable: Set = [] + var closed = false + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .sink { report in + if !closed { + ThreatDetectionCenter.close() + } else { + #expect(true == false, "Should never happen") + } + } + .store(in: &cancellable) + + Task { + try? await Task.sleep(nanoseconds: UInt64(500) * NSEC_PER_MSEC) + cont.resume() + } + } + } + + @Test func testDetectedThreadsForRootPrivileges() async throws { + var cancellable: Set = [] + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .filter{ $0.rootPrivileges != .notChecked } + .sink { report in + ThreatDetectionCenter.close() + #expect(report.rootPrivileges == .notPresent) + cont.resume() + } + .store(in: &cancellable) + } + try? await Task.sleep(nanoseconds: UInt64(100) * NSEC_PER_MSEC) + } + + @Test func testDetectedThreadsForHooks() async throws { + var cancellable: Set = [] + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .filter{ $0.hooks != .notChecked } + .sink { report in + ThreatDetectionCenter.close() + // as the pipeline differs to local machine + #expect(report.hooks != .notChecked) + cont.resume() + } + .store(in: &cancellable) + } + try? await Task.sleep(nanoseconds: UInt64(100) * NSEC_PER_MSEC) + } + + @Test func testDetectedThreadsForSimulator() async throws { + var cancellable: Set = [] + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .filter{ $0.simulator != .notChecked } + .sink { report in + ThreatDetectionCenter.close() + #expect(report.simulator == .present) + cont.resume() + } + .store(in: &cancellable) + } + try? await Task.sleep(nanoseconds: UInt64(100) * NSEC_PER_MSEC) + } + + @Test func testDetectedThreadsForDebugger() async throws { + var cancellable: Set = [] + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .filter{ $0.debugger != .notChecked } + .sink { report in + ThreatDetectionCenter.close() + #expect(report.debugger == .notPresent) + cont.resume() + } + .store(in: &cancellable) + } + try? await Task.sleep(nanoseconds: UInt64(100) * NSEC_PER_MSEC) + } + + + @Test func testDetectedThreadsForDevicePasscode() async throws { + var cancellable: Set = [] + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .filter{ $0.devicePasscode != .notChecked } + .sink { report in + ThreatDetectionCenter.close() + #expect(report.devicePasscode == .notPresent) + cont.resume() + } + .store(in: &cancellable) + } + try? await Task.sleep(nanoseconds: UInt64(100) * NSEC_PER_MSEC) + } + + @Test func testDetectedThreadsForHardwareCryptography() async throws { + var cancellable: Set = [] + await withCheckedContinuation { cont in + ThreatDetectionCenter.threatReports + .filter{ $0.hardwareCryptography != .notChecked } + .sink { report in + ThreatDetectionCenter.close() + switch (report.hardwareCryptography) { + case .exception(_): _ = report + default: #expect(report.hardwareCryptography == ThreatStatus.exception(ThreatDetectionException.checkNotPossible(""))) + } + cont.resume() + } + .store(in: &cancellable) + } + try? await Task.sleep(nanoseconds: UInt64(100) * NSEC_PER_MSEC) + } +}