Updates to scanner

This commit is contained in:
Mark Gregory
2026-10-22 12:49:07 +01:00
parent 9970beadaf
commit c522b161da
8 changed files with 417 additions and 4 deletions
+14
View File
@@ -7,6 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
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 */; };
@@ -15,6 +20,13 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
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>"; };
@@ -39,6 +51,7 @@
000000000000000000000065 /* Models.swift */,
000000000000000000000066 /* SSHScanner.swift */,
000000000000000000000067 /* AuditStore.swift */,
A10000000000000000000001 /* VulnerabilityService.swift */,
000000000000000000000068 /* ContentView.swift */,
);
path = PHPGuard;
@@ -110,6 +123,7 @@
0000000000000000000000C9 /* Models.swift in Sources */,
0000000000000000000000CA /* SSHScanner.swift in Sources */,
0000000000000000000000CB /* AuditStore.swift in Sources */,
A10000000000000000000002 /* VulnerabilityService.swift in Sources */,
0000000000000000000000CC /* ContentView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -0,0 +1,194 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
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 */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
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>"; };
/* 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 */,
000000000000000000000068 /* ContentView.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 */,
);
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 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 */,
);
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;
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;
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
View File
@@ -104,6 +104,7 @@ struct ContentView: View {
dataSection("Nginx FastCGI routes", result.nginxUpstreams)
dataSection("Installed 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)
+46 -1
View File
@@ -28,14 +28,59 @@ struct AuditResult: Codable, Identifiable {
var nginxUpstreams: [String] = []
var packageVersions: [String] = []
var availableUpdates: [String] = []
var packageOrigins: [String] = []
var dockerPHP: [String] = []
var packageAuditStatus = "Not checked"
var dockerAuditStatus = "Not checked"
var findings: [Finding] = []
var rawOutput = ""
var error: String? = nil
}
enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, packageAuditStatus, dockerAuditStatus, findings, rawOutput, error, id }
init(serverID: UUID, serverName: String) { self.serverID = serverID; self.serverName = serverName }
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
serverID = try container.decode(UUID.self, forKey: .serverID)
serverName = try container.decode(String.self, forKey: .serverName)
scannedAt = try container.decodeIfPresent(Date.self, forKey: .scannedAt) ?? Date()
hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? ""
os = try container.decodeIfPresent(String.self, forKey: .os) ?? ""
phpVersions = try container.decodeIfPresent([String].self, forKey: .phpVersions) ?? []
fpmServices = try container.decodeIfPresent([String].self, forKey: .fpmServices) ?? []
nginxUpstreams = try container.decodeIfPresent([String].self, forKey: .nginxUpstreams) ?? []
packageVersions = try container.decodeIfPresent([String].self, forKey: .packageVersions) ?? []
availableUpdates = try container.decodeIfPresent([String].self, forKey: .availableUpdates) ?? []
packageOrigins = try container.decodeIfPresent([String].self, forKey: .packageOrigins) ?? []
dockerPHP = try container.decodeIfPresent([String].self, forKey: .dockerPHP) ?? []
packageAuditStatus = try container.decodeIfPresent(String.self, forKey: .packageAuditStatus) ?? "Not checked"
dockerAuditStatus = try container.decodeIfPresent(String.self, forKey: .dockerAuditStatus) ?? "Not checked"
findings = try container.decodeIfPresent([Finding].self, forKey: .findings) ?? []
rawOutput = try container.decodeIfPresent(String.self, forKey: .rawOutput) ?? ""
error = try container.decodeIfPresent(String.self, forKey: .error)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(serverID, forKey: .serverID)
try container.encode(serverName, forKey: .serverName)
try container.encode(scannedAt, forKey: .scannedAt)
try container.encode(hostname, forKey: .hostname)
try container.encode(os, forKey: .os)
try container.encode(phpVersions, forKey: .phpVersions)
try container.encode(fpmServices, forKey: .fpmServices)
try container.encode(nginxUpstreams, forKey: .nginxUpstreams)
try container.encode(packageVersions, forKey: .packageVersions)
try container.encode(availableUpdates, forKey: .availableUpdates)
try container.encode(packageOrigins, forKey: .packageOrigins)
try container.encode(dockerPHP, forKey: .dockerPHP)
try container.encode(packageAuditStatus, forKey: .packageAuditStatus)
try container.encode(dockerAuditStatus, forKey: .dockerAuditStatus)
try container.encode(findings, forKey: .findings)
try container.encode(rawOutput, forKey: .rawOutput)
try container.encodeIfPresent(error, forKey: .error)
try container.encode(id, forKey: .id)
}
}
struct CVERecord: Codable, Identifiable {
var id: String
+20 -2
View File
@@ -28,6 +28,18 @@ enum SSHScanner {
else
printf 'Package manager not supported\n'
fi
printf '===PACKAGE_ORIGINS===\n'
if command -v apt-cache >/dev/null 2>&1; then
dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}"
apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /'
done
else
printf 'APT metadata unavailable\n'
fi
printf '===DOCKER===\n'
if command -v docker >/dev/null 2>&1; then
ids=$(docker ps -q 2>/dev/null)
@@ -36,7 +48,7 @@ enum SSHScanner {
else
for id in $ids; do
name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,')
version=$(docker exec "$id" sh -c 'php -r '''echo PHP_VERSION;''' 2>/dev/null' 2>/dev/null)
version=$(docker exec "$id" php -v 2>/dev/null | head -1)
if [ -n "$version" ]; then printf '%s %s\n' "$name" "$version"; fi
done
fi
@@ -103,8 +115,11 @@ enum SSHScanner {
result.nginxUpstreams = fields["NGINX"] ?? []
result.packageVersions = fields["PACKAGES"] ?? []
result.availableUpdates = fields["UPDATES"] ?? []
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
result.dockerPHP = (fields["DOCKER"] ?? []).filter { !$0.hasPrefix("Docker ") && $0 != "No running containers" }
result.packageAuditStatus = result.availableUpdates.contains("Package manager not supported") ? "Unsupported package manager" : "APT upgrade inventory (not a security advisory assessment)"
result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable")
? "APT package provenance unavailable"
: "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification"
result.dockerAuditStatus = fields["DOCKER"]?.first(where: { $0.hasPrefix("Docker ") || $0 == "No running containers" }) ?? "Running containers inspected where accessible"
if result.phpVersions.isEmpty {
result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers."))
@@ -119,6 +134,9 @@ enum SSHScanner {
for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") {
result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure."))
}
if result.packageOrigins.contains("APT metadata unavailable") {
result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified."))
}
for line in result.dockerPHP {
result.findings.append(Finding(severity: "Info", title: "PHP runtime in Docker", detail: line + ". Container image security status requires a separate image/advisory assessment."))
}
+120
View File
@@ -0,0 +1,120 @@
import Foundation
enum VulnerabilityService {
enum ServiceError: LocalizedError {
case invalidResponse
case httpError(Int)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from the vulnerability database."
case .httpError(let code):
return "Vulnerability database returned HTTP \(code)."
}
}
}
private struct NVDResponse: Decodable {
let vulnerabilities: [Entry]
}
private struct Entry: Decodable {
let cve: CVE
}
private struct CVE: Decodable {
let id: String
let published: String
let descriptions: [Description]
let metrics: Metrics?
}
private struct Description: Decodable {
let lang: String
let value: String
}
private struct Metrics: Decodable {
let cvssMetricV31: [Metric]?
let cvssMetricV30: [Metric]?
let cvssMetricV2: [Metric]?
}
private struct Metric: Decodable {
let cvssData: CVSSData
}
private struct CVSSData: Decodable {
let baseScore: Double?
let baseSeverity: String?
}
static func discover() async throws -> [CVERecord] {
var components = URLComponents(
string: "https://services.nvd.nist.gov/rest/json/cves/2.0"
)!
components.queryItems = [
URLQueryItem(name: "keywordSearch", value: "PHP"),
URLQueryItem(name: "resultsPerPage", value: "100")
]
guard let url = components.url else {
throw ServiceError.invalidResponse
}
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
)
request.setValue(
"PHPGuard/1.2",
forHTTPHeaderField: "User-Agent"
)
let (data, response) = try await URLSession.shared.data(
for: request
)
guard let http = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
throw ServiceError.httpError(http.statusCode)
}
let decoded = try JSONDecoder().decode(
NVDResponse.self,
from: data
)
return decoded.vulnerabilities.map { entry in
let cve = entry.cve
let description = cve.descriptions.first {
$0.lang == "en"
}?.value ?? "No description available."
let metric =
cve.metrics?.cvssMetricV31?.first ??
cve.metrics?.cvssMetricV30?.first ??
cve.metrics?.cvssMetricV2?.first
return CVERecord(
id: cve.id,
description: description,
published: cve.published,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore
)
}
.sorted { $0.published > $1.published }
}
}
+22
View File
@@ -0,0 +1,22 @@
# PHPGuard 1.3.0 — Release Notes
- Added read-only APT package provenance and candidate version inventory for PHP CLI and PHP-FPM packages.
- Added package-origin visibility to server details and exported JSON reports.
- Corrected the Docker PHP version probe.
- Preserved compatibility with earlier stored scan results.
- Updated build number to 4 and version to 1.3.0.
- Retained the Debug build and signed/notarized DMG release scripts.
## Limitations
APT origin and version information **does not** establish whether a specific CVE is fixed. Ubuntu security backports, package provenance, and vendor advisories still require verification. The CVE Research tab lists keyword-search results, not confirmed server exposures. Scans are read-only and do not remediate systems.
## Build on macOS
`./Scripts/build.sh`
## Release on macOS
`NOTARY_PROFILE=orion-notary ./Scripts/release.sh`
Requires Xcode, a Developer ID Application signing identity, and a configured notarytool keychain profile.
-1
View File
@@ -8,7 +8,6 @@ IDENTITY="${SIGNING_IDENTITY:-$(security find-identity -v -p codesigning | sed -
[[ -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; }
VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$ROOT/.build/Build/Products/Release/PHPGuard.app/Contents/Info.plist" 2>/dev/null || true)"
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"