mirror of
https://github.com/VGEAREN/Stockbar.git
synced 2026-10-06 14:33:11 +08:00
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:
co-authored by
Claude Opus 4.6
parent
00f142f091
commit
66b77aee04
@@ -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) {
|
||||
|
||||
@@ -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: - 设置快捷访问(视图直接绑定这些属性)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user