From 2c90bcb365ea127ce671836dc7114817a7152fde Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 22 Oct 2026 15:11:17 +0100 Subject: [PATCH] Updates to scanner with CVE saving and auto update --- PHPGuard.xcodeproj/project.pbxproj | 2 + .../project.pbxproj.bak-20261022-145021 | 233 +++++ .../project.pbxproj.bak-20261022-145149 | 235 +++++ .../AppIcon.appiconset/Contents.json | 62 +- .../Contents.json.bak-20261022-145149 | 14 + PHPGuard/AuditStore.swift | 42 + PHPGuard/AuditStore.swift.bak-20261022-144244 | 202 +++++ PHPGuard/ContentView.swift | 66 +- .../ContentView.swift.bak-20261022-144244 | 821 +++++++++++++++++ .../ContentView.swift.bak-20261022-151101 | 838 ++++++++++++++++++ Scripts/build.sh | 35 +- Scripts/build.sh.bak-20261022-145251 | 11 + Scripts/install-app-icon.sh | 40 + Scripts/release.sh | 49 + Scripts/release.sh.bak-20261022-143914 | 36 + 15 files changed, 2669 insertions(+), 17 deletions(-) create mode 100644 PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145021 create mode 100644 PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145149 create mode 100644 PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json.bak-20261022-145149 create mode 100644 PHPGuard/AuditStore.swift.bak-20261022-144244 create mode 100644 PHPGuard/ContentView.swift.bak-20261022-144244 create mode 100644 PHPGuard/ContentView.swift.bak-20261022-151101 create mode 100755 Scripts/build.sh.bak-20261022-145251 create mode 100755 Scripts/install-app-icon.sh create mode 100755 Scripts/release.sh.bak-20261022-143914 diff --git a/PHPGuard.xcodeproj/project.pbxproj b/PHPGuard.xcodeproj/project.pbxproj index 1fdb41b..c89e554 100644 --- a/PHPGuard.xcodeproj/project.pbxproj +++ b/PHPGuard.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; MARKETING_VERSION = 1.9.0; CURRENT_PROJECT_VERSION = 10; @@ -195,6 +196,7 @@ ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; MARKETING_VERSION = 1.9.0; CURRENT_PROJECT_VERSION = 10; diff --git a/PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145021 b/PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145021 new file mode 100644 index 0000000..1fdb41b --- /dev/null +++ b/PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145021 @@ -0,0 +1,233 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + C10000000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000001 /* Assets.xcassets */; }; + A10000000000000000000002 /* VulnerabilityService.swift in Sources */ = { + isa = PBXBuildFile; + fileRef = A10000000000000000000001 /* VulnerabilityService.swift */; + }; + + 0000000000000000000000C8 /* PHPGuardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000064 /* PHPGuardApp.swift */; }; + 0000000000000000000000C9 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000065 /* Models.swift */; }; + 0000000000000000000000CA /* SSHScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000066 /* SSHScanner.swift */; }; + 0000000000000000000000CB /* AuditStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000067 /* AuditStore.swift */; }; + 0000000000000000000000CC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000068 /* ContentView.swift */; }; + B20000000000000000000001 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* SettingsView.swift */; }; + B20000000000000000000002 /* ReportPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* ReportPDF.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + C10000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A10000000000000000000001 /* VulnerabilityService.swift */ = { + isa = PBXFileReference; + lastKnownFileType = sourcecode.swift; + path = VulnerabilityService.swift; + sourceTree = ""; + }; + + 000000000000000000000030 /* PHPGuard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PHPGuard.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000064 /* PHPGuardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPGuardApp.swift; sourceTree = ""; }; + 000000000000000000000065 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 000000000000000000000066 /* SSHScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHScanner.swift; sourceTree = ""; }; + 000000000000000000000067 /* AuditStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditStore.swift; sourceTree = ""; }; + 000000000000000000000068 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B10000000000000000000001 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B10000000000000000000002 /* ReportPDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportPDF.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 000000000000000000000010 = { + isa = PBXGroup; + children = ( + 000000000000000000000011 /* PHPGuard */, + 000000000000000000000012 /* Products */, + ); + sourceTree = ""; + }; + 000000000000000000000011 /* PHPGuard */ = { + isa = PBXGroup; + children = ( + 000000000000000000000064 /* PHPGuardApp.swift */, + 000000000000000000000065 /* Models.swift */, + 000000000000000000000066 /* SSHScanner.swift */, + 000000000000000000000067 /* AuditStore.swift */, + A10000000000000000000001 /* VulnerabilityService.swift */, + 000000000000000000000068 /* ContentView.swift */, + B10000000000000000000002 /* ReportPDF.swift */, + B10000000000000000000001 /* SettingsView.swift */, + ); + path = PHPGuard; + sourceTree = ""; + }; + 000000000000000000000012 /* Products */ = { + isa = PBXGroup; + children = ( + 000000000000000000000030 /* PHPGuard.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 000000000000000000000020 /* PHPGuard */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000042 /* Build configuration list for PBXNativeTarget "PHPGuard" */; + buildPhases = ( + 000000000000000000000021 /* Sources */, + B10000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PHPGuard; + productName = PHPGuard; + productReference = 000000000000000000000030 /* PHPGuard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 000000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + TargetAttributes = { + 000000000000000000000020 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 000000000000000000000041 /* Build configuration list for PBXProject "PHPGuard" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 000000000000000000000010; + productRefGroup = 000000000000000000000012 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 000000000000000000000020 /* PHPGuard */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B10000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C10000000000000000000002 /* Assets.xcassets in Resources */, + ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 000000000000000000000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0000000000000000000000C8 /* PHPGuardApp.swift in Sources */, + 0000000000000000000000C9 /* Models.swift in Sources */, + 0000000000000000000000CA /* SSHScanner.swift in Sources */, + 0000000000000000000000CB /* AuditStore.swift in Sources */, + 0000000000000000000000CC /* ContentView.swift in Sources */, + A10000000000000000000002 /* VulnerabilityService.swift in Sources */, + B20000000000000000000002 /* ReportPDF.swift in Sources */, + B20000000000000000000001 /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 000000000000000000000051 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 000000000000000000000052 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 000000000000000000000053 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; + MARKETING_VERSION = 1.9.0; + CURRENT_PROJECT_VERSION = 10; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 000000000000000000000054 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; + MARKETING_VERSION = 1.9.0; + CURRENT_PROJECT_VERSION = 10; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 000000000000000000000041 /* Build configuration list for PBXProject "PHPGuard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000051 /* Debug */, + 000000000000000000000052 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 000000000000000000000042 /* Build configuration list for PBXNativeTarget "PHPGuard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000053 /* Debug */, + 000000000000000000000054 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 000000000000000000000001 /* Project object */; +} diff --git a/PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145149 b/PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145149 new file mode 100644 index 0000000..c89e554 --- /dev/null +++ b/PHPGuard.xcodeproj/project.pbxproj.bak-20261022-145149 @@ -0,0 +1,235 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + C10000000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000001 /* Assets.xcassets */; }; + A10000000000000000000002 /* VulnerabilityService.swift in Sources */ = { + isa = PBXBuildFile; + fileRef = A10000000000000000000001 /* VulnerabilityService.swift */; + }; + + 0000000000000000000000C8 /* PHPGuardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000064 /* PHPGuardApp.swift */; }; + 0000000000000000000000C9 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000065 /* Models.swift */; }; + 0000000000000000000000CA /* SSHScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000066 /* SSHScanner.swift */; }; + 0000000000000000000000CB /* AuditStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000067 /* AuditStore.swift */; }; + 0000000000000000000000CC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000068 /* ContentView.swift */; }; + B20000000000000000000001 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* SettingsView.swift */; }; + B20000000000000000000002 /* ReportPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* ReportPDF.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + C10000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A10000000000000000000001 /* VulnerabilityService.swift */ = { + isa = PBXFileReference; + lastKnownFileType = sourcecode.swift; + path = VulnerabilityService.swift; + sourceTree = ""; + }; + + 000000000000000000000030 /* PHPGuard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PHPGuard.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000064 /* PHPGuardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPGuardApp.swift; sourceTree = ""; }; + 000000000000000000000065 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 000000000000000000000066 /* SSHScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHScanner.swift; sourceTree = ""; }; + 000000000000000000000067 /* AuditStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditStore.swift; sourceTree = ""; }; + 000000000000000000000068 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B10000000000000000000001 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B10000000000000000000002 /* ReportPDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportPDF.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 000000000000000000000010 = { + isa = PBXGroup; + children = ( + 000000000000000000000011 /* PHPGuard */, + 000000000000000000000012 /* Products */, + ); + sourceTree = ""; + }; + 000000000000000000000011 /* PHPGuard */ = { + isa = PBXGroup; + children = ( + 000000000000000000000064 /* PHPGuardApp.swift */, + 000000000000000000000065 /* Models.swift */, + 000000000000000000000066 /* SSHScanner.swift */, + 000000000000000000000067 /* AuditStore.swift */, + A10000000000000000000001 /* VulnerabilityService.swift */, + 000000000000000000000068 /* ContentView.swift */, + B10000000000000000000002 /* ReportPDF.swift */, + B10000000000000000000001 /* SettingsView.swift */, + ); + path = PHPGuard; + sourceTree = ""; + }; + 000000000000000000000012 /* Products */ = { + isa = PBXGroup; + children = ( + 000000000000000000000030 /* PHPGuard.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 000000000000000000000020 /* PHPGuard */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000042 /* Build configuration list for PBXNativeTarget "PHPGuard" */; + buildPhases = ( + 000000000000000000000021 /* Sources */, + B10000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PHPGuard; + productName = PHPGuard; + productReference = 000000000000000000000030 /* PHPGuard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 000000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + TargetAttributes = { + 000000000000000000000020 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 000000000000000000000041 /* Build configuration list for PBXProject "PHPGuard" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 000000000000000000000010; + productRefGroup = 000000000000000000000012 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 000000000000000000000020 /* PHPGuard */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B10000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C10000000000000000000002 /* Assets.xcassets in Resources */, + ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 000000000000000000000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0000000000000000000000C8 /* PHPGuardApp.swift in Sources */, + 0000000000000000000000C9 /* Models.swift in Sources */, + 0000000000000000000000CA /* SSHScanner.swift in Sources */, + 0000000000000000000000CB /* AuditStore.swift in Sources */, + 0000000000000000000000CC /* ContentView.swift in Sources */, + A10000000000000000000002 /* VulnerabilityService.swift in Sources */, + B20000000000000000000002 /* ReportPDF.swift in Sources */, + B20000000000000000000001 /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 000000000000000000000051 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 000000000000000000000052 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 000000000000000000000053 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleIconName = AppIcon; + INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; + MARKETING_VERSION = 1.9.0; + CURRENT_PROJECT_VERSION = 10; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 000000000000000000000054 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleIconName = AppIcon; + INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; + MARKETING_VERSION = 1.9.0; + CURRENT_PROJECT_VERSION = 10; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 000000000000000000000041 /* Build configuration list for PBXProject "PHPGuard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000051 /* Debug */, + 000000000000000000000052 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 000000000000000000000042 /* Build configuration list for PBXNativeTarget "PHPGuard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000053 /* Debug */, + 000000000000000000000054 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 000000000000000000000001 /* Project object */; +} diff --git a/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json b/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json index 83842e9..a0de1f0 100644 --- a/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,10 +1,64 @@ { "images": [ { - "filename": "icon-1024.png", - "idiom": "universal", - "platform": "macos", - "size": "1024x1024" + "filename": "icon_16x16_1x.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon_16x16_2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon_32x32_1x.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon_32x32_2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon_128x128_1x.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon_128x128_2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon_256x256_1x.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon_256x256_2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon_512x512_1x.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon_512x512_2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" } ], "info": { diff --git a/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json.bak-20261022-145149 b/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json.bak-20261022-145149 new file mode 100644 index 0000000..83842e9 --- /dev/null +++ b/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json.bak-20261022-145149 @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "macos", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/PHPGuard/AuditStore.swift b/PHPGuard/AuditStore.swift index 2f78bb7..c5e244b 100644 --- a/PHPGuard/AuditStore.swift +++ b/PHPGuard/AuditStore.swift @@ -10,6 +10,12 @@ import SwiftUI @Published var scanServerName = "" @Published var scanServerHost = "" + @Published var scanStage = "Preparing scan" + @Published var scanDetail = "" + @Published var scanFraction = 0.0 + @Published var scanCompletedChecks = 0 + @Published var scanTotalChecks = 0 + @Published var lastError: String? @Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } } @Published var cveLastChecked: Date? { didSet { saveCVECache() } } @@ -94,6 +100,11 @@ import SwiftUI scanServerName = server.name scanServerHost = server.host scanProgressVisible = true + scanStage = "Connecting to server" + scanDetail = "Establishing SSH connection and collecting evidence" + scanFraction = 0.05 + scanCompletedChecks = 0 + scanTotalChecks = 0 scanning.insert(server.id) Task { defer { @@ -104,6 +115,11 @@ import SwiftUI } do { var report = try await SSHScanner.scan(server) + + scanStage = "SSH assessment complete" + scanDetail = "Analysing PHP runtimes and preparing vulnerability checks" + scanFraction = 0.60 + report.cveAssessmentStatus = "Checking NVD PHP product applicability" let runtimeLines = report.phpVersions + report.dockerPHP var versions: [String: String] = [:] @@ -117,13 +133,23 @@ import SwiftUI let canonical = parts.prefix(3).joined(separator: ".") versions[canonical] = "PHP " + canonical } + scanTotalChecks = versions.count + scanCompletedChecks = 0 + if versions.isEmpty { + scanStage = "No PHP versions detected" + scanDetail = "Skipping NVD vulnerability assessment" + scanFraction = 0.95 + report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version" } else { var all: [CVEServerAssessment] = [] var failures: [String] = [] // Sequential requests respect unauthenticated NVD API rate limits. for version in versions.keys.sorted() { + scanStage = "Checking PHP vulnerabilities" + scanDetail = "Querying NVD for PHP " + version + do { let records = try await VulnerabilityService.assessPHPVersion( version, runtime: versions[version] ?? version) @@ -131,14 +157,30 @@ import SwiftUI } catch { failures.append("PHP \(version): \(error.localizedDescription)") } + + scanCompletedChecks += 1 + + scanFraction = 0.60 + ( + 0.35 * Double(scanCompletedChecks) / + Double(max(1, scanTotalChecks)) + ) } + report.cveAssessments = all.sorted { $0.cveID > $1.cveID } report.cveAssessmentStatus = failures.isEmpty ? "Upstream CPE assessment complete; distribution fixes unverified" : "Partial/unknown: " + failures.joined(separator: "; ") report.cveAssessedAt = Date() } + scanStage = "Saving assessment" + scanDetail = "Writing the completed security report" + scanFraction = 0.98 + results.insert(report, at: 0) + + scanStage = "Assessment complete" + scanDetail = "Security report saved" + scanFraction = 1.0 } catch { var failed = AuditResult(serverID: server.id, serverName: server.name) diff --git a/PHPGuard/AuditStore.swift.bak-20261022-144244 b/PHPGuard/AuditStore.swift.bak-20261022-144244 new file mode 100644 index 0000000..2f78bb7 --- /dev/null +++ b/PHPGuard/AuditStore.swift.bak-20261022-144244 @@ -0,0 +1,202 @@ +import Foundation +import SwiftUI + +@MainActor final class AuditStore: ObservableObject { + @Published var servers: [Server] = [] { didSet { saveServers() } } + @Published var results: [AuditResult] = [] { didSet { saveResults() } } + @Published var scanning: Set = [] + @Published var scanProgressVisible = false + @Published var scanStartedAt = Date() + @Published var scanServerName = "" + @Published var scanServerHost = "" + + @Published var lastError: String? + @Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } } + @Published var cveLastChecked: Date? { didSet { saveCVECache() } } + @Published var cveError: String? + @Published var fetchingCVEs = false + + private var folder: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let path = base.appendingPathComponent("PHPGuard", isDirectory: true) + try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + return path + } + init() { + let defaults = UserDefaults.standard + autoRefreshCVEs = defaults.object(forKey: "phpguard.autoRefreshCVEs") as? Bool ?? true + showTechnicalDetails = defaults.bool(forKey: "phpguard.showTechnicalDetails") + confirmBeforeClearing = defaults.object(forKey: "phpguard.confirmClear") as? Bool ?? true + preferredReportAccent = defaults.string(forKey: "phpguard.reportAccent") ?? "Blue" + retainHistoryLimit = defaults.object(forKey: "phpguard.historyLimit") as? Int ?? 250 + servers = load("servers.json") ?? [] + results = (load("results.json") ?? []).map { Self.normalized($0) } + if let cache: CVECache = load("cve-cache.json") { + discoveredCVEs = cache.records + cveLastChecked = cache.fetchedAt + } + } + private static func normalized(_ source: AuditResult) -> AuditResult { + var result = source + result.dockerPHP = source.dockerPHP.filter { + !$0.contains("OCI runtime exec failed") && !$0.contains("executable file not found") + } + result.findings.removeAll { + $0.title == "PHP runtime in Docker" && + ($0.detail.contains("OCI runtime exec failed") || $0.detail.contains("executable file not found")) + } + if result.dockerPHP.count != source.dockerPHP.count { + result.dockerAuditStatus = "Legacy scan: missing PHP executables excluded; rescan for structured Docker coverage" + } + return result + } + func importReport() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let report = Self.normalized(try decoder.decode(AuditResult.self, from: Data(contentsOf: url))) + guard !results.contains(where: { $0.id == report.id }) else { + lastError = "This report has already been imported." + return + } + results.insert(report, at: 0) + } catch { lastError = "Could not import JSON report: \(error.localizedDescription)" } + } + private func load(_ name: String) -> T? { + guard let data = try? Data(contentsOf: folder.appendingPathComponent(name)) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + private func save(_ value: T, as name: String) { + guard let data = try? JSONEncoder().encode(value) else { return } + try? data.write(to: folder.appendingPathComponent(name), options: .atomic) + } + private struct CVECache: Codable { + let records: [CVERecord] + let fetchedAt: Date? + } + private func saveCVECache() { + // Do not overwrite a previously successful download with an empty response. + guard !discoveredCVEs.isEmpty else { return } + save(CVECache(records: discoveredCVEs, fetchedAt: cveLastChecked), as: "cve-cache.json") + } + private func saveServers() { save(servers, as: "servers.json") } + private func saveResults() { save(Array(results.prefix(max(1, retainHistoryLimit))), as: "results.json") } + func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } } + func scan(_ server: Server) { + guard !scanning.contains(server.id) else { return } + if scanning.isEmpty { + scanStartedAt = Date() + } + scanServerName = server.name + scanServerHost = server.host + scanProgressVisible = true + scanning.insert(server.id) + Task { + defer { + scanning.remove(server.id) + if scanning.isEmpty { + scanProgressVisible = false + } + } + do { + var report = try await SSHScanner.scan(server) + report.cveAssessmentStatus = "Checking NVD PHP product applicability" + let runtimeLines = report.phpVersions + report.dockerPHP + var versions: [String: String] = [:] + for line in runtimeLines { + let tokens = line.split(whereSeparator: { $0.isWhitespace }) + guard let token = tokens.last else { continue } + let version = String(token) + let parts = version.split(separator: ".") + guard parts.count >= 3, + parts.prefix(3).allSatisfy({ Int($0) != nil }) else { continue } + let canonical = parts.prefix(3).joined(separator: ".") + versions[canonical] = "PHP " + canonical + } + if versions.isEmpty { + report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version" + } else { + var all: [CVEServerAssessment] = [] + var failures: [String] = [] + // Sequential requests respect unauthenticated NVD API rate limits. + for version in versions.keys.sorted() { + do { + let records = try await VulnerabilityService.assessPHPVersion( + version, runtime: versions[version] ?? version) + all.append(contentsOf: records) + } catch { + failures.append("PHP \(version): \(error.localizedDescription)") + } + } + report.cveAssessments = all.sorted { $0.cveID > $1.cveID } + report.cveAssessmentStatus = failures.isEmpty + ? "Upstream CPE assessment complete; distribution fixes unverified" + : "Partial/unknown: " + failures.joined(separator: "; ") + report.cveAssessedAt = Date() + } + results.insert(report, at: 0) + } + catch { + var failed = AuditResult(serverID: server.id, serverName: server.name) + failed.error = error.localizedDescription + results.insert(failed, at: 0) + lastError = "\(server.name): \(error.localizedDescription)" + } + } + } + func scanAll() { servers.forEach { scan($0) } } + func refreshCVEsOnLaunch() { + // Preserve the local cache while checking NVD for a newer result set. + if autoRefreshCVEs { refreshCVEs() } + } + func refreshCVEs() { + guard !fetchingCVEs else { return } + fetchingCVEs = true + cveError = nil + Task { + defer { fetchingCVEs = false } + do { + let downloaded = try await VulnerabilityService.discover() + guard !downloaded.isEmpty else { + cveError = "NVD returned no records; keeping the previous saved results." + return + } + discoveredCVEs = downloaded + cveLastChecked = Date() + } catch { cveError = error.localizedDescription } + } + } + @Published var showTechnicalDetails = false + @Published var confirmBeforeClearing = true + @Published var preferredReportAccent = "Blue" + @Published var autoRefreshCVEs = true + @Published var retainHistoryLimit = 250 + + func clearHistory() { results.removeAll() } + func removeHistory(_ offsets: IndexSet) { results.remove(atOffsets: offsets) } + func exportPDF(_ result: AuditResult) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.pdf] + panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).pdf" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try ReportPDF.write(result, to: url, accent: preferredReportAccent) } + catch { lastError = "PDF export failed: \(error.localizedDescription)" } + } + func export(_ result: AuditResult) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).json" + if panel.runModal() == .OK, let url = panel.url { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(result).write(to: url) + } catch { lastError = error.localizedDescription } + } + } +} diff --git a/PHPGuard/ContentView.swift b/PHPGuard/ContentView.swift index b075591..52b0530 100644 --- a/PHPGuard/ContentView.swift +++ b/PHPGuard/ContentView.swift @@ -2,6 +2,8 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var store: AuditStore + @State private var showInstalledPackages = false + @State private var showAPTOrigins = false @State private var selection: UUID? @State private var showingEditor = false @State private var editing: Server? @@ -135,7 +137,25 @@ struct ContentView: View { packages: result.packageVersions ) dataSection("Available PHP package upgrades", result.availableUpdates) - dataSection("APT package origins and candidate revisions", result.packageOrigins) + DisclosureGroup( + isExpanded: $showAPTOrigins + ) { + dataSection( + "APT package origins and candidate revisions", + result.packageOrigins + ) + } label: { + Label( + "APT package origins and candidate revisions", + systemImage: "shippingbox" + ) + .font(.headline) + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) dataSection("PHP detected in running Docker containers", result.dockerPHP) Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) @@ -457,7 +477,8 @@ struct ContentView: View { } private func packageInventoryCard(_ values: [String]) -> some View { - inventoryCard( + DisclosureGroup(isExpanded: $showInstalledPackages) { + inventoryCard( title: "Installed Packages", subtitle: "PHP package inventory", icon: "shippingbox.fill", @@ -606,6 +627,24 @@ struct ContentView: View { } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } } + + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) } struct ServerEditor: View { @@ -747,12 +786,13 @@ struct ScanProgressView: View { HStack { VStack(alignment: .leading, spacing: 5) { - Text("Scanning") + Text(store.scanStage) .font(.subheadline.weight(.semibold)) - Text("Collecting PHP, package, Docker and web-server evidence") + Text(store.scanDetail) .font(.caption) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } Spacer() @@ -761,10 +801,26 @@ struct ScanProgressView: View { .controlSize(.regular) } - ProgressView() + ProgressView(value: store.scanFraction, total: 1.0) .progressViewStyle(.linear) .tint(Color.accentColor) + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + HStack { Label( "\(store.scanning.count) active", diff --git a/PHPGuard/ContentView.swift.bak-20261022-144244 b/PHPGuard/ContentView.swift.bak-20261022-144244 new file mode 100644 index 0000000..b075591 --- /dev/null +++ b/PHPGuard/ContentView.swift.bak-20261022-144244 @@ -0,0 +1,821 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + dataSection("APT package origins and candidate revisions", result.packageOrigins) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.") + .font(.caption).foregroundStyle(.secondary) + if result.cveAssessments.isEmpty { + Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.") + .foregroundStyle(.secondary) + } + ForEach(result.cveAssessments) { item in + VStack(alignment: .leading, spacing: 5) { + HStack { + Link(item.cveID, destination: URL(string: item.referenceURL)!) + .font(.headline) + Text(item.severity).font(.caption.bold()) + Spacer() + Text(item.status).foregroundStyle(.orange) + } + Text(item.runtime + " — " + item.summary).lineLimit(3) + Text(item.evidence).font(.caption).foregroundStyle(.secondary) + }.padding(10) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10)) + } + } + HStack { + Text("Security findings").font(.title2.bold()) + Spacer() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { + index, line in + + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } + + private func inventoryEmptyState(_ message: String) -> some View { + HStack(spacing: 9) { + Image(systemName: "minus.circle") + .foregroundStyle(.tertiary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 12) + } + + private func dataSection(_ title: String, _ values: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22) + Text(title).font(.headline) + Spacer() + Text("\(values.count)").font(.caption).foregroundStyle(.secondary) + } + if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } + else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14)) + } + private var cveView: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("PHP CVE research").font(.largeTitle.bold()) + Spacer() + if store.fetchingCVEs { ProgressView() } + Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs) + } + Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.") + .foregroundStyle(.secondary) + if let date = store.cveLastChecked { + Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch") + .foregroundStyle(.secondary) + } else { + Text("No cached CVE research yet · Automatically fetched at launch") + .foregroundStyle(.secondary) + } + if let error = store.cveError { Text(error).foregroundStyle(.red) } + List(store.discoveredCVEs) { cve in + VStack(alignment: .leading, spacing: 6) { + HStack { + Link(cve.id, destination: URL(string: cve.referenceURL)!) + .font(.headline) + Spacer() + Text(cve.severity + (cve.score.map { " (\($0))" } ?? "")) + Text(String(cve.published.prefix(10))).foregroundStyle(.secondary) + } + Text(cve.description).lineLimit(4).textSelection(.enabled) + }.padding(.vertical, 5) + } + }.padding(24) + } + private var historyView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Scan history").font(.largeTitle.bold()) + Text("\(store.results.count) saved scans").foregroundStyle(.secondary) + } + Spacer() + Button("Clear History", role: .destructive) { + if confirmClearHistory { showClearHistoryConfirmation = true } + else { store.clearHistory() } + }.disabled(store.results.isEmpty) + }.padding(20) + List { + ForEach(store.results) { result in + HStack(spacing: 12) { + Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill") + .foregroundStyle(result.error == nil ? .green : .red) + VStack(alignment: .leading, spacing: 4) { + Text(result.serverName).font(.headline) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } + else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations") + .font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil) + Button("JSON") { store.export(result) }.disabled(result.error != nil) + }.padding(.vertical, 5) + }.onDelete(perform: store.removeHistory) + } + } + .confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear History", role: .destructive) { store.clearHistory() } + } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } + } + +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text("Scanning") + .font(.subheadline.weight(.semibold)) + + Text("Collecting PHP, package, Docker and web-server evidence") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView() + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/PHPGuard/ContentView.swift.bak-20261022-151101 b/PHPGuard/ContentView.swift.bak-20261022-151101 new file mode 100644 index 0000000..6281ec3 --- /dev/null +++ b/PHPGuard/ContentView.swift.bak-20261022-151101 @@ -0,0 +1,838 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + dataSection("APT package origins and candidate revisions", result.packageOrigins) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.") + .font(.caption).foregroundStyle(.secondary) + if result.cveAssessments.isEmpty { + Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.") + .foregroundStyle(.secondary) + } + ForEach(result.cveAssessments) { item in + VStack(alignment: .leading, spacing: 5) { + HStack { + Link(item.cveID, destination: URL(string: item.referenceURL)!) + .font(.headline) + Text(item.severity).font(.caption.bold()) + Spacer() + Text(item.status).foregroundStyle(.orange) + } + Text(item.runtime + " — " + item.summary).lineLimit(3) + Text(item.evidence).font(.caption).foregroundStyle(.secondary) + }.padding(10) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10)) + } + } + HStack { + Text("Security findings").font(.title2.bold()) + Spacer() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { + index, line in + + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } + + private func inventoryEmptyState(_ message: String) -> some View { + HStack(spacing: 9) { + Image(systemName: "minus.circle") + .foregroundStyle(.tertiary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 12) + } + + private func dataSection(_ title: String, _ values: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22) + Text(title).font(.headline) + Spacer() + Text("\(values.count)").font(.caption).foregroundStyle(.secondary) + } + if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } + else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14)) + } + private var cveView: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("PHP CVE research").font(.largeTitle.bold()) + Spacer() + if store.fetchingCVEs { ProgressView() } + Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs) + } + Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.") + .foregroundStyle(.secondary) + if let date = store.cveLastChecked { + Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch") + .foregroundStyle(.secondary) + } else { + Text("No cached CVE research yet · Automatically fetched at launch") + .foregroundStyle(.secondary) + } + if let error = store.cveError { Text(error).foregroundStyle(.red) } + List(store.discoveredCVEs) { cve in + VStack(alignment: .leading, spacing: 6) { + HStack { + Link(cve.id, destination: URL(string: cve.referenceURL)!) + .font(.headline) + Spacer() + Text(cve.severity + (cve.score.map { " (\($0))" } ?? "")) + Text(String(cve.published.prefix(10))).foregroundStyle(.secondary) + } + Text(cve.description).lineLimit(4).textSelection(.enabled) + }.padding(.vertical, 5) + } + }.padding(24) + } + private var historyView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Scan history").font(.largeTitle.bold()) + Text("\(store.results.count) saved scans").foregroundStyle(.secondary) + } + Spacer() + Button("Clear History", role: .destructive) { + if confirmClearHistory { showClearHistoryConfirmation = true } + else { store.clearHistory() } + }.disabled(store.results.isEmpty) + }.padding(20) + List { + ForEach(store.results) { result in + HStack(spacing: 12) { + Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill") + .foregroundStyle(result.error == nil ? .green : .red) + VStack(alignment: .leading, spacing: 4) { + Text(result.serverName).font(.headline) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } + else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations") + .font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil) + Button("JSON") { store.export(result) }.disabled(result.error != nil) + }.padding(.vertical, 5) + }.onDelete(perform: store.removeHistory) + } + } + .confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear History", role: .destructive) { store.clearHistory() } + } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } + } + +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(store.scanStage) + .font(.subheadline.weight(.semibold)) + + Text(store.scanDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView(value: store.scanFraction, total: 1.0) + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/Scripts/build.sh b/Scripts/build.sh index 93d3a30..790a560 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -1,11 +1,30 @@ #!/bin/zsh set -euo pipefail + ROOT="$(cd "$(dirname "$0")/.." && pwd)" -CONFIG="${CONFIGURATION:-Debug}" -DERIVED="${DERIVED_DATA_PATH:-$ROOT/.build}" -command -v xcodebuild >/dev/null || { echo "Xcode command line tools required" >&2; exit 1; } -xcodebuild -project "$ROOT/PHPGuard.xcodeproj" -scheme PHPGuard -configuration "$CONFIG" -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build -APP="$DERIVED/Build/Products/$CONFIG/PHPGuard.app" -[[ -d "$APP" ]] || { echo "Built application not found: $APP" >&2; exit 1; } -echo "Built: $APP" -if [[ "${LAUNCH_AFTER_BUILD:-1}" == "1" ]]; then open "$APP"; fi +cd "$ROOT" + +DERIVED="$ROOT/.build" + +xcodebuild \ + -project PHPGuard.xcodeproj \ + -scheme PHPGuard \ + -configuration Debug \ + -derivedDataPath "$DERIVED" \ + CODE_SIGNING_ALLOWED=NO \ + build + +APP="$DERIVED/Build/Products/Debug/PHPGuard.app" + +[[ -d "$APP" ]] || { + echo "PHPGuard.app not found" >&2 + exit 1 +} + +"$ROOT/Scripts/install-app-icon.sh" "$APP" + +echo +echo "Build complete: $APP" + +pkill -x PHPGuard 2>/dev/null || true +open "$APP" diff --git a/Scripts/build.sh.bak-20261022-145251 b/Scripts/build.sh.bak-20261022-145251 new file mode 100755 index 0000000..93d3a30 --- /dev/null +++ b/Scripts/build.sh.bak-20261022-145251 @@ -0,0 +1,11 @@ +#!/bin/zsh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONFIG="${CONFIGURATION:-Debug}" +DERIVED="${DERIVED_DATA_PATH:-$ROOT/.build}" +command -v xcodebuild >/dev/null || { echo "Xcode command line tools required" >&2; exit 1; } +xcodebuild -project "$ROOT/PHPGuard.xcodeproj" -scheme PHPGuard -configuration "$CONFIG" -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build +APP="$DERIVED/Build/Products/$CONFIG/PHPGuard.app" +[[ -d "$APP" ]] || { echo "Built application not found: $APP" >&2; exit 1; } +echo "Built: $APP" +if [[ "${LAUNCH_AFTER_BUILD:-1}" == "1" ]]; then open "$APP"; fi diff --git a/Scripts/install-app-icon.sh b/Scripts/install-app-icon.sh new file mode 100755 index 0000000..74f2d39 --- /dev/null +++ b/Scripts/install-app-icon.sh @@ -0,0 +1,40 @@ +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOURCE="$ROOT/PHPGuard/Assets.xcassets/AppIcon.appiconset/icon-1024.png" + +[[ -f "$SOURCE" ]] || { + echo "Missing PHPGuard icon: $SOURCE" >&2 + exit 1 +} + +APP="${1:?Pass the path to PHPGuard.app}" +RESOURCES="$APP/Contents/Resources" + +mkdir -p "$RESOURCES" + +ICONSET="$(mktemp -d)/PHPGuard.iconset" +mkdir -p "$ICONSET" + +for size in 16 32 128 256 512; do + sips -s format png -z "$size" "$size" "$SOURCE" \ + --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + + double=$((size * 2)) + + sips -s format png -z "$double" "$double" "$SOURCE" \ + --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done + +iconutil -c icns "$ICONSET" -o "$RESOURCES/PHPGuard.icns" + +/usr/libexec/PlistBuddy \ + -c "Delete :CFBundleIconFile" \ + "$APP/Contents/Info.plist" 2>/dev/null || true + +/usr/libexec/PlistBuddy \ + -c "Add :CFBundleIconFile string PHPGuard.icns" \ + "$APP/Contents/Info.plist" + +echo "PHPGuard application icon installed." diff --git a/Scripts/release.sh b/Scripts/release.sh index 11632de..49eb035 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -12,6 +12,22 @@ DERIVED="$ROOT/.build" xcodebuild -project "$ROOT/PHPGuard.xcodeproj" -scheme PHPGuard -configuration Release -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build APP="$DERIVED/Build/Products/Release/PHPGuard.app" [[ -d "$APP" ]] || { echo "Missing Release app" >&2; exit 1; } + +echo "=== VERIFY APPLICATION ICON ===" + +APP_ICON_NAME=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "$APP/Contents/Info.plist" 2>/dev/null || true) + +if [[ -z "$APP_ICON_NAME" ]]; then + echo "ERROR: Application icon is missing from Info.plist" >&2 + exit 1 +fi + +echo "Application icon: $APP_ICON_NAME" + +if [[ ! -f "$APP/Contents/Resources/Assets.car" ]]; then + echo "ERROR: Compiled asset catalog missing" >&2 + exit 1 +fi VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" mkdir -p "$ROOT/dist" STAGING="$(mktemp -d)" @@ -29,7 +45,40 @@ if [[ -f "$INSTALLER_ICON" ]]; then mkdir -p "$STAGING/.background" cp "$INSTALLER_ICON" "$STAGING/.background/PHPGuard-Installer.png" fi +# Generate a proper macOS installer icon. +INSTALLER_PNG="$ROOT/PHPGuard/Assets.xcassets/InstallerIcon.imageset/icon.png" +ICONSET="$ROOT/dist/PHPGuardInstaller.iconset" +INSTALLER_ICNS="$ROOT/dist/PHPGuardInstaller.icns" + +if [[ -f "$INSTALLER_PNG" ]]; then + rm -rf "$ICONSET" + mkdir -p "$ICONSET" + + for size in 16 32 128 256 512; do + sips -s format png -z "$size" "$size" "$INSTALLER_PNG" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + + double=$((size * 2)) + + sips -s format png -z "$double" "$double" "$INSTALLER_PNG" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null + done + + iconutil -c icns "$ICONSET" -o "$INSTALLER_ICNS" + rm -rf "$ICONSET" +else + echo "ERROR: Installer artwork missing" >&2 + exit 1 +fi + hdiutil create -volname "PHPGuard $VERSION" -srcfolder "$STAGING" -ov -format UDZO "$DMG" + +# Apply custom Finder icon to the DMG. +if command -v fileicon >/dev/null 2>&1; then + fileicon set "$DMG" "$INSTALLER_ICNS" +else + echo "WARNING: fileicon is not installed." + echo "Install with: brew install fileicon" + echo "DMG created without a custom Finder icon." +fi xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" diff --git a/Scripts/release.sh.bak-20261022-143914 b/Scripts/release.sh.bak-20261022-143914 new file mode 100755 index 0000000..11632de --- /dev/null +++ b/Scripts/release.sh.bak-20261022-143914 @@ -0,0 +1,36 @@ +#!/bin/zsh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +command -v xcodebuild >/dev/null || { echo "Xcode is required" >&2; exit 1; } +command -v hdiutil >/dev/null || { echo "hdiutil missing" >&2; exit 1; } +IDENTITY="${SIGNING_IDENTITY:-$(security find-identity -v -p codesigning | sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1)}" +[[ -n "$IDENTITY" ]] || { echo "Developer ID Application signing identity required. Set SIGNING_IDENTITY." >&2; exit 1; } +PROFILE="${NOTARY_PROFILE:-}" +[[ -n "$PROFILE" ]] || { echo "Set NOTARY_PROFILE to an existing notarytool keychain profile." >&2; exit 1; } +DERIVED="$ROOT/.build" +xcodebuild -project "$ROOT/PHPGuard.xcodeproj" -scheme PHPGuard -configuration Release -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build +APP="$DERIVED/Build/Products/Release/PHPGuard.app" +[[ -d "$APP" ]] || { echo "Missing Release app" >&2; exit 1; } +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" +mkdir -p "$ROOT/dist" +STAGING="$(mktemp -d)" +trap 'rm -rf "$STAGING"' EXIT +cp -R "$APP" "$STAGING/PHPGuard.app" +# No --deep signing: sign embedded executables individually if added in future. +codesign --force --options runtime --timestamp --sign "$IDENTITY" "$STAGING/PHPGuard.app" +codesign --verify --strict --verbose=2 "$STAGING/PHPGuard.app" +DMG="$ROOT/dist/PHPGuard-$VERSION.dmg" +rm -f "$DMG" +ln -s /Applications "$STAGING/Applications" +# Include a branded installer graphic in the disk image. +INSTALLER_ICON="$ROOT/PHPGuard/Assets.xcassets/InstallerIcon.imageset/icon.png" +if [[ -f "$INSTALLER_ICON" ]]; then + mkdir -p "$STAGING/.background" + cp "$INSTALLER_ICON" "$STAGING/.background/PHPGuard-Installer.png" +fi +hdiutil create -volname "PHPGuard $VERSION" -srcfolder "$STAGING" -ov -format UDZO "$DMG" +xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait +xcrun stapler staple "$DMG" +xcrun stapler validate "$DMG" +echo "Release ready: $DMG"