Stabilize iOS/watchOS/tvOS apps and add cross-platform audit remediation
This commit is contained in:
6
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
6
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public enum AppNotifications {
|
||||
public static let createdNewWorkout = Notification.Name("CreatedNewWorkout")
|
||||
}
|
||||
|
||||
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public struct BoundedFIFOQueue<Element> {
|
||||
private var storage: [Element] = []
|
||||
public let maxCount: Int
|
||||
|
||||
public init(maxCount: Int) {
|
||||
self.maxCount = max(1, maxCount)
|
||||
}
|
||||
|
||||
public var count: Int {
|
||||
storage.count
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
storage.isEmpty
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public mutating func enqueue(_ element: Element) -> Int {
|
||||
var droppedCount = 0
|
||||
if storage.count >= maxCount {
|
||||
droppedCount = storage.count - maxCount + 1
|
||||
storage.removeFirst(droppedCount)
|
||||
}
|
||||
storage.append(element)
|
||||
return droppedCount
|
||||
}
|
||||
|
||||
public mutating func dequeueAll() -> [Element] {
|
||||
let elements = storage
|
||||
storage.removeAll()
|
||||
return elements
|
||||
}
|
||||
|
||||
public mutating func clear() {
|
||||
storage.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public enum RuntimeSeverity: String, Sendable {
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
public struct RuntimeEvent: Sendable {
|
||||
public let severity: RuntimeSeverity
|
||||
public let message: String
|
||||
public let metadata: [String: String]
|
||||
public let timestamp: Date
|
||||
|
||||
public init(severity: RuntimeSeverity, message: String, metadata: [String: String], timestamp: Date = Date()) {
|
||||
self.severity = severity
|
||||
self.message = message
|
||||
self.metadata = metadata
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public final class RuntimeReporter {
|
||||
public typealias Sink = @Sendable (RuntimeEvent) -> Void
|
||||
|
||||
public static let shared = RuntimeReporter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.werkout.sharedcore", category: "runtime")
|
||||
private let lock = NSLock()
|
||||
private var sink: Sink?
|
||||
|
||||
private init() {}
|
||||
|
||||
public func setSink(_ sink: Sink?) {
|
||||
lock.lock()
|
||||
self.sink = sink
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
public func recordError(_ message: String, metadata: [String: String] = [:]) {
|
||||
record(.error, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
public func recordWarning(_ message: String, metadata: [String: String] = [:]) {
|
||||
record(.warning, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
public func recordInfo(_ message: String, metadata: [String: String] = [:]) {
|
||||
record(.info, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
private func record(_ severity: RuntimeSeverity, message: String, metadata: [String: String]) {
|
||||
let flattenedMetadata = metadata
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
|
||||
let logMessage = flattenedMetadata.isEmpty ? message : "\(message) | \(flattenedMetadata)"
|
||||
|
||||
switch severity {
|
||||
case .info:
|
||||
logger.info("\(logMessage, privacy: .public)")
|
||||
case .warning:
|
||||
logger.warning("\(logMessage, privacy: .public)")
|
||||
case .error:
|
||||
logger.error("\(logMessage, privacy: .public)")
|
||||
}
|
||||
|
||||
lock.lock()
|
||||
let sink = self.sink
|
||||
lock.unlock()
|
||||
|
||||
sink?(RuntimeEvent(severity: severity, message: message, metadata: metadata))
|
||||
}
|
||||
}
|
||||
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
public enum TokenSecurity {
|
||||
public static let defaultRotationWindow: TimeInterval = 60 * 60
|
||||
private static let authPrefixes = ["token", "bearer"]
|
||||
|
||||
// Basic high-entropy hex token detector for accidental commits.
|
||||
private static let tokenRegex = try? NSRegularExpression(pattern: "\\b[a-fA-F0-9]{32,}\\b")
|
||||
|
||||
public static func containsPotentialHardcodedToken(in text: String) -> Bool {
|
||||
guard let tokenRegex else {
|
||||
return false
|
||||
}
|
||||
let range = NSRange(location: 0, length: text.utf16.count)
|
||||
return tokenRegex.firstMatch(in: text, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
public static func isRedactedToken(_ token: String?) -> Bool {
|
||||
guard let token else { return false }
|
||||
let upper = token.uppercased()
|
||||
return upper.contains("REDACTED") || upper.contains("YOUR_TOKEN") || upper.contains("PLACEHOLDER")
|
||||
}
|
||||
|
||||
public static func sanitizeToken(_ token: String?) -> String? {
|
||||
guard let rawToken = token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
rawToken.isEmpty == false else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalized = normalizeAuthPrefix(rawToken)
|
||||
guard normalized.isEmpty == false,
|
||||
isRedactedToken(normalized) == false else {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
public static func jwtExpiration(_ token: String) -> Date? {
|
||||
let segments = token.split(separator: ".")
|
||||
guard segments.count == 3 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let payloadSegment = String(segments[1])
|
||||
guard let payloadData = base64URLDecode(payloadSegment),
|
||||
let object = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
|
||||
let exp = object["exp"] as? TimeInterval else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Date(timeIntervalSince1970: exp)
|
||||
}
|
||||
|
||||
public static func isExpired(_ token: String?, now: Date = Date()) -> Bool {
|
||||
guard let token = sanitizeToken(token) else {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let expiration = jwtExpiration(token) else {
|
||||
// Non-JWT tokens cannot be locally validated for expiry.
|
||||
return false
|
||||
}
|
||||
|
||||
return expiration <= now
|
||||
}
|
||||
|
||||
public static func shouldRotate(_ token: String?, now: Date = Date(), rotationWindow: TimeInterval = defaultRotationWindow) -> Bool {
|
||||
guard let token = sanitizeToken(token),
|
||||
let expiration = jwtExpiration(token) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return expiration.timeIntervalSince(now) <= rotationWindow
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ input: String) -> Data? {
|
||||
var value = input
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
let remainder = value.count % 4
|
||||
if remainder > 0 {
|
||||
value.append(String(repeating: "=", count: 4 - remainder))
|
||||
}
|
||||
|
||||
return Data(base64Encoded: value)
|
||||
}
|
||||
|
||||
private static func normalizeAuthPrefix(_ token: String) -> String {
|
||||
let lowercased = token.lowercased()
|
||||
for prefix in authPrefixes {
|
||||
if lowercased == prefix {
|
||||
return ""
|
||||
}
|
||||
|
||||
let prefixed = "\(prefix) "
|
||||
if lowercased.hasPrefix(prefixed) {
|
||||
return String(token.dropFirst(prefixed.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public enum WatchPayloadValidationError: Error, Equatable {
|
||||
case emptyPayload
|
||||
case payloadTooLarge(actualBytes: Int, maxBytes: Int)
|
||||
case decodeFailure
|
||||
}
|
||||
|
||||
public enum WatchPayloadValidation {
|
||||
public static let defaultMaxPayloadBytes = 256 * 1024
|
||||
|
||||
public static func validate(_ payload: Data, maxBytes: Int = defaultMaxPayloadBytes) -> WatchPayloadValidationError? {
|
||||
guard payload.isEmpty == false else {
|
||||
return .emptyPayload
|
||||
}
|
||||
|
||||
guard payload.count <= maxBytes else {
|
||||
return .payloadTooLarge(actualBytes: payload.count, maxBytes: maxBytes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func decode<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
from payload: Data,
|
||||
maxBytes: Int = defaultMaxPayloadBytes,
|
||||
decoder: JSONDecoder = JSONDecoder()
|
||||
) throws -> T {
|
||||
if let validationError = validate(payload, maxBytes: maxBytes) {
|
||||
throw validationError
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: payload)
|
||||
} catch {
|
||||
throw WatchPayloadValidationError.decodeFailure
|
||||
}
|
||||
}
|
||||
}
|
||||
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
public struct WorkoutValidationIssue: Equatable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
|
||||
public init(code: String, message: String) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
public enum WorkoutValidation {
|
||||
public static func validateSupersets(_ supersets: [[String: Any]]) -> [WorkoutValidationIssue] {
|
||||
var issues = [WorkoutValidationIssue]()
|
||||
|
||||
if supersets.isEmpty {
|
||||
issues.append(WorkoutValidationIssue(code: "empty_supersets", message: "Workout requires at least one superset."))
|
||||
return issues
|
||||
}
|
||||
|
||||
for (supersetIndex, superset) in supersets.enumerated() {
|
||||
let rounds = superset["rounds"] as? Int ?? 0
|
||||
if rounds <= 0 {
|
||||
issues.append(WorkoutValidationIssue(code: "invalid_rounds", message: "Superset \(supersetIndex + 1) must have at least one round."))
|
||||
}
|
||||
|
||||
let exercises = superset["exercises"] as? [[String: Any]] ?? []
|
||||
if exercises.isEmpty {
|
||||
issues.append(WorkoutValidationIssue(code: "empty_exercises", message: "Superset \(supersetIndex + 1) must contain at least one exercise."))
|
||||
continue
|
||||
}
|
||||
|
||||
for (exerciseIndex, exercise) in exercises.enumerated() {
|
||||
let reps = exercise["reps"] as? Int ?? 0
|
||||
let duration = exercise["duration"] as? Int ?? 0
|
||||
if reps <= 0 && duration <= 0 {
|
||||
issues.append(
|
||||
WorkoutValidationIssue(
|
||||
code: "invalid_exercise_payload",
|
||||
message: "Exercise \(exerciseIndex + 1) in superset \(supersetIndex + 1) needs reps or duration."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user