Updates to scanner with CVE saving and auto update
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Executable
+11
@@ -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
|
||||
Executable
+40
@@ -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."
|
||||
@@ -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"
|
||||
|
||||
Executable
+36
@@ -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"
|
||||
Reference in New Issue
Block a user