Updates to scanner with CVE saving and auto update

This commit is contained in:
Mark Gregory
2026-10-22 15:11:17 +01:00
parent 1da7234864
commit 2c90bcb365
15 changed files with 2669 additions and 17 deletions
+2
View File
@@ -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;
@@ -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 = "<group>"; };
A10000000000000000000001 /* VulnerabilityService.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = VulnerabilityService.swift;
sourceTree = "<group>";
};
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 = "<group>"; };
000000000000000000000065 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
000000000000000000000066 /* SSHScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHScanner.swift; sourceTree = "<group>"; };
000000000000000000000067 /* AuditStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditStore.swift; sourceTree = "<group>"; };
000000000000000000000068 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B10000000000000000000001 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B10000000000000000000002 /* ReportPDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportPDF.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
000000000000000000000010 = {
isa = PBXGroup;
children = (
000000000000000000000011 /* PHPGuard */,
000000000000000000000012 /* Products */,
);
sourceTree = "<group>";
};
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 = "<group>";
};
000000000000000000000012 /* Products */ = {
isa = PBXGroup;
children = (
000000000000000000000030 /* PHPGuard.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -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 = "<group>"; };
A10000000000000000000001 /* VulnerabilityService.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = VulnerabilityService.swift;
sourceTree = "<group>";
};
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 = "<group>"; };
000000000000000000000065 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
000000000000000000000066 /* SSHScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHScanner.swift; sourceTree = "<group>"; };
000000000000000000000067 /* AuditStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditStore.swift; sourceTree = "<group>"; };
000000000000000000000068 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B10000000000000000000001 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B10000000000000000000002 /* ReportPDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportPDF.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
000000000000000000000010 = {
isa = PBXGroup;
children = (
000000000000000000000011 /* PHPGuard */,
000000000000000000000012 /* Products */,
);
sourceTree = "<group>";
};
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 = "<group>";
};
000000000000000000000012 /* Products */ = {
isa = PBXGroup;
children = (
000000000000000000000030 /* PHPGuard.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -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": {
@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "icon-1024.png",
"idiom": "universal",
"platform": "macos",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
+42
View File
@@ -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)
@@ -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<UUID> = []
@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<T: Decodable>(_ 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<T: Encodable>(_ 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 }
}
}
}
+61 -5
View File
@@ -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",
@@ -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<Content: View>(
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
)
}
}
@@ -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<Content: View>(
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
)
}
}
+27 -8
View File
@@ -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"
+11
View File
@@ -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
+40
View File
@@ -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."
+49
View File
@@ -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"
+36
View File
@@ -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"