From 66b77aee04db43835133fd3cda88a04919d13d4c Mon Sep 17 00:00:00 2001 From: vgearen Date: Thu, 19 Mar 2026 10:51:23 +0800 Subject: [PATCH] =?UTF-8?q?v1.1.4:=20Security=20hardening=20=E2=80=94=20SH?= =?UTF-8?q?A-256=20verify,=20fix=20injection,=20log=20rotation,=20error=20?= =?UTF-8?q?logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SHA-256 hash verification for downloaded DMG in auto-update - Fix shell command injection: use positional args instead of path interpolation - Add log rotation (truncate to 500KB when exceeding 1MB) - Replace critical try? with do/catch + logToFile for data persistence Co-Authored-By: Claude Opus 4.6 (1M context) --- StockMonitor.xcodeproj/project.pbxproj | 8 ++-- StockMonitor/Services/UpdateChecker.swift | 48 +++++++++++++++++++---- StockMonitor/State/AppState.swift | 20 +++++++--- StockMonitor/Utils/AppLogger.swift | 18 +++++++++ StockMonitor/Utils/LaunchAtLogin.swift | 8 +++- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/StockMonitor.xcodeproj/project.pbxproj b/StockMonitor.xcodeproj/project.pbxproj index 4ecbfe0..c11ffdd 100644 --- a/StockMonitor.xcodeproj/project.pbxproj +++ b/StockMonitor.xcodeproj/project.pbxproj @@ -339,7 +339,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 1.1.3; + MARKETING_VERSION = 1.1.4; PRODUCT_BUNDLE_IDENTIFIER = com.vgearen.Stockbar; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -373,7 +373,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 1.1.3; + MARKETING_VERSION = 1.1.4; PRODUCT_BUNDLE_IDENTIFIER = com.vgearen.Stockbar; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -393,7 +393,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.1.3; + MARKETING_VERSION = 1.1.4; PRODUCT_BUNDLE_IDENTIFIER = com.vgearen.StockbarTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -412,7 +412,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.1.3; + MARKETING_VERSION = 1.1.4; PRODUCT_BUNDLE_IDENTIFIER = com.vgearen.StockbarTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/StockMonitor/Services/UpdateChecker.swift b/StockMonitor/Services/UpdateChecker.swift index baf3ffb..29949b6 100644 --- a/StockMonitor/Services/UpdateChecker.swift +++ b/StockMonitor/Services/UpdateChecker.swift @@ -1,9 +1,12 @@ import Foundation import AppKit import Combine +import CryptoKit private struct GitHubRelease: Decodable { let tagName: String + let htmlUrl: String + let body: String? let assets: [Asset] struct Asset: Decodable { @@ -17,6 +20,8 @@ private struct GitHubRelease: Decodable { enum CodingKeys: String, CodingKey { case tagName = "tag_name" + case htmlUrl = "html_url" + case body case assets } } @@ -69,12 +74,12 @@ final class UpdateChecker: ObservableObject { // 3. 下载 + 安装 status = "正在下载 v\(latest)…" - await downloadAndInstall(url: downloadURL, version: latest) + await downloadAndInstall(url: downloadURL, version: latest, releaseBody: release.body) } // MARK: - 下载安装 - private func downloadAndInstall(url: URL, version: String) async { + private func downloadAndInstall(url: URL, version: String, releaseBody: String?) async { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent("StockbarUpdate-\(UUID().uuidString)") try? fm.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -88,6 +93,21 @@ final class UpdateChecker: ObservableObject { return finish("下载失败") } + // SHA-256 校验 + if let expectedHash = parseSHA256(from: releaseBody) { + guard let dmgData = try? Data(contentsOf: dmgPath) else { + return finish("校验失败") + } + let actualHash = SHA256.hash(data: dmgData).map { String(format: "%02x", $0) }.joined() + guard actualHash.lowercased() == expectedHash.lowercased() else { + logToFile("UpdateChecker: SHA-256 mismatch, expected=\(expectedHash) actual=\(actualHash)") + return finish("校验失败") + } + logToFile("UpdateChecker: SHA-256 verified OK") + } else { + logToFile("UpdateChecker: no SHA-256 in release body, skipping verification") + } + status = "正在安装…" // 挂载 DMG @@ -114,15 +134,15 @@ final class UpdateChecker: ObservableObject { } shell("/usr/bin/hdiutil", "detach", mountPoint, "-quiet") - // 写替换脚本并执行 + // 写替换脚本并执行(使用位置参数避免路径注入) let currentApp = Bundle.main.bundlePath let script = """ #!/bin/bash sleep 1 - rm -rf "\(currentApp)" - cp -R "\(newApp)" "\(currentApp)" - open "\(currentApp)" - rm -rf "\(tempDir.path)" + rm -rf "$1" + cp -R "$2" "$1" + open "$1" + rm -rf "$3" """ let scriptPath = tempDir.appendingPathComponent("update.sh").path try? script.write(toFile: scriptPath, atomically: true, encoding: .utf8) @@ -131,12 +151,24 @@ final class UpdateChecker: ObservableObject { status = "正在重启…" let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") - proc.arguments = [scriptPath] + proc.arguments = [scriptPath, currentApp, newApp, tempDir.path] try? proc.run() NSApplication.shared.terminate(nil) } + /// 从 release body 中提取 SHA-256 哈希值(格式: SHA-256: ) + private func parseSHA256(from body: String?) -> String? { + guard let body = body else { return nil } + let pattern = #"SHA-256:\s*([0-9a-fA-F]{64})"# + guard let range = body.range(of: pattern, options: .regularExpression) else { return nil } + let match = String(body[range]) + // Extract just the hex part after "SHA-256:" + let components = match.split(separator: ":", maxSplits: 1) + guard components.count == 2 else { return nil } + return components[1].trimmingCharacters(in: .whitespaces) + } + // MARK: - 辅助 private func finish(_ msg: String) { diff --git a/StockMonitor/State/AppState.swift b/StockMonitor/State/AppState.swift index 3794a80..f98f12c 100644 --- a/StockMonitor/State/AppState.swift +++ b/StockMonitor/State/AppState.swift @@ -58,14 +58,22 @@ final class AppState: ObservableObject { if stocks.isEmpty, !Self.loadStocks().isEmpty { return } guard let data = try? JSONEncoder().encode(stocks) else { return } Self.backupIfNeeded() - try? data.write(to: Self.stocksFileURL, options: .atomic) + do { + try data.write(to: Self.stocksFileURL, options: .atomic) + } catch { + logToFile("saveStocks: failed to write stocks.json: \(error)") + } } private func saveSettings(_ settings: AppSettings) { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted guard let data = try? encoder.encode(settings) else { return } - try? data.write(to: Self.settingsFileURL, options: .atomic) + do { + try data.write(to: Self.settingsFileURL, options: .atomic) + } catch { + logToFile("saveSettings: failed to write settings.json: \(error)") + } } /// 滚动备份 stocks.json,最多保留 10 份 @@ -78,13 +86,13 @@ final class AppState: ObservableObject { let from = dir.appendingPathComponent("stocks.\(i).json") let to = dir.appendingPathComponent("stocks.\(i + 1).json") if fm.fileExists(atPath: from.path) { - try? fm.removeItem(at: to) - try? fm.moveItem(at: from, to: to) + do { try fm.removeItem(at: to) } catch { logToFile("backupIfNeeded: removeItem \(to.lastPathComponent) failed: \(error)") } + do { try fm.moveItem(at: from, to: to) } catch { logToFile("backupIfNeeded: moveItem \(from.lastPathComponent) -> \(to.lastPathComponent) failed: \(error)") } } } let backup = dir.appendingPathComponent("stocks.1.json") - try? fm.removeItem(at: backup) - try? fm.copyItem(at: src, to: backup) + do { try fm.removeItem(at: backup) } catch { logToFile("backupIfNeeded: removeItem \(backup.lastPathComponent) failed: \(error)") } + do { try fm.copyItem(at: src, to: backup) } catch { logToFile("backupIfNeeded: copyItem to \(backup.lastPathComponent) failed: \(error)") } } // MARK: - 设置快捷访问(视图直接绑定这些属性) diff --git a/StockMonitor/Utils/AppLogger.swift b/StockMonitor/Utils/AppLogger.swift index e499135..5e25367 100644 --- a/StockMonitor/Utils/AppLogger.swift +++ b/StockMonitor/Utils/AppLogger.swift @@ -11,6 +11,24 @@ func logToFile(_ message: String) { try? fm.createDirectory(at: logDir, withIntermediateDirectories: true) let logFile = logDir.appendingPathComponent("app.log") + // Log rotation: if file exceeds 1MB, keep only last 500KB + let maxSize: UInt64 = 1_000_000 + let keepSize: Int = 500_000 + if let attrs = try? fm.attributesOfItem(atPath: logFile.path), + let fileSize = attrs[.size] as? UInt64, + fileSize > maxSize, + let data = try? Data(contentsOf: logFile), + data.count > keepSize { + let tail = data.suffix(keepSize) + // Find first newline in tail to avoid partial line + if let newlineIndex = tail.firstIndex(of: UInt8(ascii: "\n")) { + let clean = tail.suffix(from: tail.index(after: newlineIndex)) + try? clean.write(to: logFile) + } else { + try? tail.write(to: logFile) + } + } + let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] \(message)\n" if fm.fileExists(atPath: logFile.path), diff --git a/StockMonitor/Utils/LaunchAtLogin.swift b/StockMonitor/Utils/LaunchAtLogin.swift index 9b6ed57..8c48eac 100644 --- a/StockMonitor/Utils/LaunchAtLogin.swift +++ b/StockMonitor/Utils/LaunchAtLogin.swift @@ -74,8 +74,12 @@ enum LaunchAtLogin { try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) if let data = data { - try? data.write(to: plistURL, options: .atomic) - logToFile("LaunchAtLogin: wrote LaunchAgent plist to \(plistURL.path)") + do { + try data.write(to: plistURL, options: .atomic) + logToFile("LaunchAtLogin: wrote LaunchAgent plist to \(plistURL.path)") + } catch { + logToFile("LaunchAtLogin: failed to write plist: \(error)") + } } }