v1.1.4: Security hardening — SHA-256 verify, fix injection, log rotation, error logging

- 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) <noreply@anthropic.com>
This commit is contained in:
vgearen
2026-03-19 10:51:23 +08:00
co-authored by Claude Opus 4.6
parent 00f142f091
commit 66b77aee04
5 changed files with 82 additions and 20 deletions
+40 -8
View File
@@ -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: <hex>)
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) {
+14 -6
View File
@@ -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: - 设置快捷访问(视图直接绑定这些属性)
+18
View File
@@ -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),
+6 -2
View File
@@ -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)")
}
}
}