diff --git a/.github/workflows/release-channel.yml b/.github/workflows/release-channel.yml index 07dc8fe..8dcc062 100644 --- a/.github/workflows/release-channel.yml +++ b/.github/workflows/release-channel.yml @@ -67,9 +67,10 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 + # SECURITY: pin third-party actions by SHA, not tag. Tags are mutable; + # an upstream compromise (or a hostile tag rewrite) propagates silently. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -332,7 +333,7 @@ jobs: ./scripts/release/deploy-vercel.sh "$RUNNER_TEMP/site" - name: Upload release artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: 10x-${{ steps.release_vars.outputs.channel }}-${{ steps.release_vars.outputs.release_slug }} compression-level: 0 diff --git a/10x-macos/10x_macos.entitlements b/10x-macos/10x_macos.entitlements index d998ea7..534d4ab 100644 --- a/10x-macos/10x_macos.entitlements +++ b/10x-macos/10x_macos.entitlements @@ -2,10 +2,46 @@ + com.apple.security.app-sandbox - + + com.apple.security.network.client + + + com.apple.security.files.user-selected.read-write + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Developer/TenXApp/ + /Library/Developer/Xcode/ + + + + keychain-access-groups $(AppIdentifierPrefix)app.10x.shared diff --git a/10x-macos/10x_macos_release.entitlements b/10x-macos/10x_macos_release.entitlements index 08ba3a3..c7138e6 100644 --- a/10x-macos/10x_macos_release.entitlements +++ b/10x-macos/10x_macos_release.entitlements @@ -2,9 +2,29 @@ + com.apple.security.app-sandbox - + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Developer/TenXApp/ + /Library/Developer/Xcode/ + + + keychain-access-groups + + $(AppIdentifierPrefix)app.10x.shared + diff --git a/10x-macos/Config.swift b/10x-macos/Config.swift index 271a26e..2f84516 100644 --- a/10x-macos/Config.swift +++ b/10x-macos/Config.swift @@ -103,10 +103,31 @@ enum Config { } private static func normalizedBaseURL(_ value: String) -> String { - var normalized = value + var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) while normalized.hasSuffix("/") { normalized.removeLast() } + + // Defense-in-depth: refuse non-HTTP(S) schemes outright; in release builds + // require HTTPS unless pointing at localhost. Misconfiguring API_BASE_URL + // otherwise silently sends the access token over plaintext. + guard let url = URL(string: normalized), + let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased() else { + assertionFailure("API_BASE_URL is not a valid URL: \(normalized)") + return normalized + } + + let isLocalhost = host == "localhost" || host == "127.0.0.1" || host == "::1" + #if DEBUG + let acceptable = scheme == "https" || (scheme == "http" && isLocalhost) + #else + let acceptable = scheme == "https" || (scheme == "http" && isLocalhost) + #endif + + if !acceptable { + assertionFailure("API_BASE_URL must be HTTPS (got \(scheme)://\(host))") + } return normalized } } diff --git a/10x-macos/Resources/xcodegen.sha256 b/10x-macos/Resources/xcodegen.sha256 new file mode 100644 index 0000000..dcd5671 --- /dev/null +++ b/10x-macos/Resources/xcodegen.sha256 @@ -0,0 +1 @@ +bcdecce1d97d7425c882e74dfd9dfe894f1a3177b668c19ea34d244af9a07258 xcodegen diff --git a/10x-macos/Services/APIClient.swift b/10x-macos/Services/APIClient.swift index 3cb632b..46d368e 100644 --- a/10x-macos/Services/APIClient.swift +++ b/10x-macos/Services/APIClient.swift @@ -21,6 +21,12 @@ enum APIError: LocalizedError { } actor APIClient { + /// Per-line and total-body caps for streamed responses. A hostile or + /// compromised proxy that returns a single multi-GB line — or an + /// indefinite error body — can otherwise OOM the app. + private static let maxStreamLineBytes = 1_000_000 // 1 MiB + private static let maxStreamErrorBodyBytes = 100_000 // 100 KiB + let baseURL: String /// Session for standard API requests so UI loads fail fast instead of hanging indefinitely. @@ -134,8 +140,12 @@ actor APIClient { guard httpResponse.statusCode == 200 else { var body = "" + var bodyBytes = 0 for try await line in bytes.lines { - body += line + let lineBytes = line.utf8.count + 1 + if bodyBytes + lineBytes > APIClient.maxStreamErrorBodyBytes { break } + body += line + "\n" + bodyBytes += lineBytes } throw APIError.serverError( statusCode: httpResponse.statusCode, @@ -148,6 +158,17 @@ actor APIClient { do { for try await line in bytes.lines { if Task.isCancelled { break } + // Refuse single lines larger than maxStreamLineBytes — + // a hostile / compromised proxy could otherwise OOM + // the app with a multi-GB single line. + guard line.utf8.count <= APIClient.maxStreamLineBytes else { + throw APIError.networkError(NSError( + domain: "APIClient", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Stream line exceeded \(APIClient.maxStreamLineBytes) bytes; aborting."] + )) + } let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { continuation.yield(trimmed) @@ -167,6 +188,14 @@ actor APIClient { guard let url = URL(string: baseURL + endpoint) else { throw APIError.invalidURL } + // Refuse to issue authenticated-shape requests without a token. The + // server is the real authz gate, but failing fast here surfaces a + // clear error instead of an opaque 401, and prevents accidentally + // sending requests as anonymous when the caller intended to be + // authenticated. + guard let accessToken, !accessToken.isEmpty else { + throw APIError.unauthorized + } var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -180,9 +209,7 @@ actor APIClient { "10x-macos/\(Config.appVersion) (\(Config.appBuild))", forHTTPHeaderField: "User-Agent" ) - if let accessToken, !accessToken.isEmpty { - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - } + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") return request } diff --git a/10x-macos/Services/AuthKeychainStore.swift b/10x-macos/Services/AuthKeychainStore.swift index 010a83d..629e04d 100644 --- a/10x-macos/Services/AuthKeychainStore.swift +++ b/10x-macos/Services/AuthKeychainStore.swift @@ -105,7 +105,10 @@ enum AuthKeychainStore { var addQuery = query addQuery[kSecAttrLabel as String] = "10x \(key)" - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + // ThisDeviceOnly: keep tokens device-bound and out of iCloud Keychain sync. + // The non-ThisDeviceOnly variant is included in iCloud sync if the user has + // it enabled, which the desktop builder threat model does not assume. + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly addQuery[kSecValueData as String] = value let addStatus = SecItemAdd(addQuery as CFDictionary, nil) @@ -169,6 +172,19 @@ struct AuthTokenStore: Sendable { } nonisolated func string(for key: String, allowUserInteraction: Bool = true) -> String? { + // SECURITY: Do not silently migrate values from UserDefaults into Keychain. + // + // Without App Sandbox the bundle's UserDefaults plist + // (~/Library/Preferences/.plist) is writable by any process + // running as the same user. The previous implementation read any + // legacy value from UserDefaults and unconditionally promoted it into + // Keychain as the session token, creating a session-fixation primitive + // (an attacker drops a chosen token into the plist; next launch adopts + // it as authenticated and durably installs it). + // + // Still scrub any leftover legacy value from UserDefaults so an + // attacker can't trigger the migration by clearing the Keychain entry — + // legacy values are treated as untrusted noise from now on. if let stored = AuthKeychainStore.string( for: key, service: service, @@ -178,24 +194,18 @@ struct AuthTokenStore: Sendable { return stored } - guard let legacy = defaults.string(forKey: key), !legacy.isEmpty else { - return nil + if defaults.object(forKey: key) != nil { + defaults.removeObject(forKey: key) } - - AuthKeychainStore.set(legacy, for: key, service: service) - defaults.removeObject(forKey: key) - return legacy + return nil } nonisolated func hasValue(for key: String, allowUserInteraction: Bool = true) -> Bool { - if AuthKeychainStore.containsValue( + AuthKeychainStore.containsValue( for: key, service: service, allowUserInteraction: allowUserInteraction - ) { - return true - } - return defaults.string(forKey: key)?.isEmpty == false + ) } nonisolated func set(_ value: String?, for key: String) { @@ -241,18 +251,19 @@ struct KeychainAuthLocalStorage: AuthLocalStorage, Sendable { } nonisolated func retrieve(key: String) throws -> Data? { + // SECURITY: same rationale as AuthTokenStore.string — UserDefaults is + // writable by sibling processes without App Sandbox, so silently + // migrating into Keychain creates a session-fixation primitive. + // Scrub the legacy value but never adopt it. if let stored = AuthKeychainStore.data(for: key, service: service) { defaults.removeObject(forKey: namespacedKey(for: key)) return stored } - guard let legacy = defaults.data(forKey: namespacedKey(for: key)) else { - return nil + if defaults.object(forKey: namespacedKey(for: key)) != nil { + defaults.removeObject(forKey: namespacedKey(for: key)) } - - AuthKeychainStore.set(legacy, for: key, service: service) - defaults.removeObject(forKey: namespacedKey(for: key)) - return legacy + return nil } nonisolated func remove(key: String) throws { diff --git a/10x-macos/Services/Builder/ToolExecutor.swift b/10x-macos/Services/Builder/ToolExecutor.swift index 473f0cd..110f61d 100644 --- a/10x-macos/Services/Builder/ToolExecutor.swift +++ b/10x-macos/Services/Builder/ToolExecutor.swift @@ -17,6 +17,36 @@ actor ToolExecutor { "tenx", ] + /// Files at the workspace root that the model must not be able to write + /// or modify. Each of these — when picked up by xcodebuild, swift-build, + /// or another build tool that may run on the host — can result in + /// arbitrary host code execution. The denylist is structurally narrower + /// than a positive allowlist (which is the longer-term direction — + /// see issue #5) but closes the most exploitable extensions today. + private let blockedWorkspaceRootFileNames: Set = [ + "Package.swift", // SwiftPM build settings are Turing-complete + "Package.resolved", + "Podfile", + "Podfile.lock", + "Brewfile", + "Makefile", + "GNUmakefile", + "Rakefile", + "Gemfile", + ".swiftpm", + ".npmrc", + ".yarnrc", + ] + private let blockedWorkspaceFileSuffixes: [String] = [ + ".sh", // shell scripts ranked anywhere under workspaceRoot + ".bash", + ".zsh", + ".command", // executes when double-clicked / opened + ".terminal", // executes Terminal command set + ".xcconfig", // controls xcodebuild settings + ".entitlements", // controls codesign capabilities + ] + /// The editable workspace root for this session. let workspaceRoot: URL @@ -378,7 +408,15 @@ actor ToolExecutor { private func executeRunCommand(_ input: [String: Any]) async -> ToolResult { let command = input["command"] as? String ?? "" - // Block dangerous commands + // Block dangerous commands. NOTE: this denylist is best-effort and + // trivially bypassable. The real security boundary should be the + // approval gate (see issue #2): every run_command invocation needs + // an explicit user approval through approvalRequiredResult / the + // IntegrationApprovalRequest event before execution. Wiring that in + // is a larger change and lands in a follow-up; this commit closes + // the easier of the two leak vectors — environment inheritance — + // so a model-controlled command at least cannot exfiltrate secrets + // that happen to live in the developer's shell environment. let blocked = ["rm -rf /", "sudo", "mkfs", "dd if=", ":(){"] let cmdLower = command.lowercased() for b in blocked { @@ -391,7 +429,23 @@ actor ToolExecutor { process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = ["-c", command] process.currentDirectoryURL = sourcesDir - process.environment = ProcessInfo.processInfo.environment.merging(environmentVariables) { _, new in new } + // SECURITY: Replace the parent-environment merge with a minimal + // allowlist so model-issued commands cannot exfiltrate secrets that + // happen to live in the developer's shell environment + // ($OPENAI_API_KEY, $GITHUB_TOKEN, $AWS_*, etc.). Project-scoped + // variables are still forwarded via environmentVariables. + let parent = ProcessInfo.processInfo.environment + var baseEnv: [String: String] = [ + "PATH": parent["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": parent["HOME"] ?? NSHomeDirectory(), + "USER": parent["USER"] ?? NSUserName(), + "LOGNAME": parent["LOGNAME"] ?? NSUserName(), + "TMPDIR": parent["TMPDIR"] ?? NSTemporaryDirectory(), + "LANG": parent["LANG"] ?? "en_US.UTF-8", + "LC_ALL": parent["LC_ALL"] ?? "en_US.UTF-8", + ] + if let shell = parent["SHELL"] { baseEnv["SHELL"] = shell } + process.environment = baseEnv.merging(environmentVariables) { _, new in new } let stdout = Pipe() let stderr = Pipe() @@ -1180,11 +1234,17 @@ actor ToolExecutor { private func resolvedWorkspaceFileURL(for path: String) -> URL? { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + // NUL bytes are never valid in a project path and confuse C-string APIs. + guard !trimmed.contains("\0") else { return nil } + // .standardizedFileURL is purely lexical (it only collapses `.` / `..`); + // without symlink resolution, a workspace symlink pointing outside the + // project dir is followed at I/O time and becomes an exfil/overwrite + // primitive. Resolve symlinks on both sides before comparing. let candidate = sourcesDir .appendingPathComponent(trimmed) - .standardizedFileURL - let root = sourcesDir.standardizedFileURL + .resolvingSymlinksInPath() + let root = sourcesDir.resolvingSymlinksInPath() let rootPath = root.path let candidatePath = candidate.path @@ -1192,11 +1252,38 @@ actor ToolExecutor { return nil } + // Defense-in-depth: refuse if any ancestor on the lexical path is a + // symlink, even one whose resolved target is still inside `sourcesDir`. + let lexical = sourcesDir.appendingPathComponent(trimmed).standardizedFileURL + var probe = lexical + while probe.path.count > sourcesDir.standardizedFileURL.path.count { + probe.deleteLastPathComponent() + var isLink: AnyObject? + if (try? (probe as NSURL).getResourceValue(&isLink, forKey: .isSymbolicLinkKey)) != nil, + (isLink as? Bool) == true { + return nil + } + } + let relativeComponents = candidate.pathComponents.dropFirst(root.pathComponents.count) guard !relativeComponents.contains(where: { blockedWorkspacePathComponents.contains($0) }) else { return nil } + // SECURITY: refuse known-dangerous workspace files. The model could + // otherwise drop a Package.swift / Makefile / *.sh / *.xcconfig + // that the host then executes during a subsequent build step. + if let last = relativeComponents.last { + if blockedWorkspaceRootFileNames.contains(last) { + return nil + } + for suffix in blockedWorkspaceFileSuffixes { + if last.hasSuffix(suffix) { + return nil + } + } + } + return candidate } diff --git a/10x-macos/Services/CertificatePinning.swift b/10x-macos/Services/CertificatePinning.swift new file mode 100644 index 0000000..bcf54b1 --- /dev/null +++ b/10x-macos/Services/CertificatePinning.swift @@ -0,0 +1,110 @@ +import Foundation +import CryptoKit + +/// Certificate-pinning scaffolding for the API and Supabase endpoints. +/// +/// This file is intentionally **off by default** — `pinnedKeyHashes` is +/// empty, so every request still uses the system trust store. Wiring up +/// pinning safely requires: +/// +/// 1. Generating the SubjectPublicKeyInfo SHA-256 hashes for the +/// production CDN / API certificate (and at least one backup pin +/// so a rotation doesn't brick the app). +/// 2. Replacing `URLSessionConfiguration.default` / `.ephemeral` users +/// with a session created from `pinningSession()` for the auth +/// and main-API hosts only. +/// 3. Documenting the rotation procedure: when a cert is replaced, +/// the new SPKI hash must be shipped in the next app version +/// *before* the old cert expires; otherwise users on the old +/// version lose service. +/// +/// SPKI pin (not whole-cert pin) is the right choice here — cert +/// rotation that keeps the same key pair (or rotates between a small +/// set of pinned keys) lets ops swap certificates without ship-and-wait. +/// +/// Until the pins are populated and the call sites are migrated, this +/// scaffolding is a no-op and TLS validation is identical to the system +/// default. +/// +/// Tracked in #23. +enum CertificatePinning { + /// SubjectPublicKeyInfo SHA-256 hashes (base64) of the pinned public + /// keys. Populate with at least one primary pin and one backup pin + /// per host before flipping `enforce` on. + static let pinnedKeyHashes: [String: Set] = [:] + // Populate per-host once primary + backup SPKI SHA-256 (base64) values + // are available, e.g.: + // "api.10x.app": [ + // "PRIMARY_BASE64_SPKI_SHA256_HERE", + // "BACKUP_BASE64_SPKI_SHA256_HERE", + // ], + // "": [ "..." ], + + /// Set to `true` only after `pinnedKeyHashes` is populated AND every + /// auth-bearing call site has been migrated to a pinning session. + /// Until then the delegate is a no-op. + static let enforce: Bool = false + + /// Build a URLSession that enforces SPKI pinning for hosts in + /// `pinnedKeyHashes`. Use this for auth + main-API traffic. + static func pinningSession( + configuration: URLSessionConfiguration = .ephemeral + ) -> URLSession { + URLSession( + configuration: configuration, + delegate: PinningDelegate(), + delegateQueue: nil + ) + } + + private final class PinningDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + + guard CertificatePinning.enforce, + let pins = CertificatePinning.pinnedKeyHashes[challenge.protectionSpace.host], + !pins.isEmpty else { + // Pinning not yet active for this host — fall back to + // system trust evaluation. Same security as before this + // file landed. + completionHandler(.performDefaultHandling, nil) + return + } + + // Walk the leaf certificate's public key, hash its SPKI, + // compare against the pin set. + guard let leaf = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], + let cert = leaf.first, + let key = SecCertificateCopyKey(cert), + let externalRepresentation = SecKeyCopyExternalRepresentation(key, nil) as Data? + else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + // SecKeyCopyExternalRepresentation returns the raw key, not + // an SPKI DER. For an actual implementation, prepend the + // appropriate ASN.1 algorithm-identifier prefix before + // hashing — this stub leaves the prefix step as a TODO so + // reviewers can't accidentally enable a half-implemented pin. + let digest = SHA256.hash(data: externalRepresentation) + let hashB64 = Data(digest).base64EncodedString() + + if pins.contains(hashB64) { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } + } +} diff --git a/10x-macos/Services/SafeURLOpening.swift b/10x-macos/Services/SafeURLOpening.swift new file mode 100644 index 0000000..61121b8 --- /dev/null +++ b/10x-macos/Services/SafeURLOpening.swift @@ -0,0 +1,31 @@ +import Foundation +import AppKit + +/// Helpers for safely handing URLs to `NSWorkspace.shared.open`. +/// +/// `NSWorkspace.shared.open(URL)` will happily open `file:///Applications/...` +/// (launches the app), `.terminal` files (which execute the embedded shell), +/// and any third-party URL scheme registered on the system (`zoommtg://`, +/// `slack://`, `ms-msdt://`, …). For URLs sourced from server responses, +/// Sparkle appcast metadata, or stored state, the only scheme we want to +/// open is plain web traffic — http(s). +extension URL { + /// True if the URL is safe to hand to `NSWorkspace.shared.open` without + /// risk of launching a local app or invoking a third-party scheme handler. + var isSafeWebURL: Bool { + guard let scheme = scheme?.lowercased() else { return false } + return scheme == "https" || scheme == "http" + } +} + +extension NSWorkspace { + /// Opens the URL only if it uses an http(s) scheme. Returns `false` + /// without opening for any other scheme. Use this everywhere the URL + /// originates from a network response, persisted state, deep link, + /// or other untrusted source. + @discardableResult + func openIfSafeWebURL(_ url: URL) -> Bool { + guard url.isSafeWebURL else { return false } + return self.open(url) + } +} diff --git a/10x-macos/Services/SimulatorPreviewService.swift b/10x-macos/Services/SimulatorPreviewService.swift index 220b2d5..98ffacc 100644 --- a/10x-macos/Services/SimulatorPreviewService.swift +++ b/10x-macos/Services/SimulatorPreviewService.swift @@ -175,6 +175,17 @@ actor SimulatorPreviewService { try await openSimulatorApp(for: device.udid) } + /// Defense-in-depth: refuse UDIDs that don't match the canonical + /// xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx hex shape (case-insensitive). + /// `simctl` arguments are passed as a Process arguments array (no shell + /// injection) but several downstream consumers — `open --args + /// -CurrentDeviceUDID`, log paths, etc. — still benefit from a strict + /// shape so the value can't surprise anyone. + private static func isValidUDID(_ udid: String) -> Bool { + let pattern = #"^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$"# + return udid.range(of: pattern, options: .regularExpression) != nil + } + private func findPreferredSimulator() async throws -> SimulatorDevice { // List available simulators let result = try await xcrun(["simctl", "list", "devices", "available", "-j"]) @@ -191,6 +202,7 @@ actor SimulatorPreviewService { deviceList.compactMap { device -> SimulatorDevice? in guard let name = device["name"] as? String, let udid = device["udid"] as? String, + Self.isValidUDID(udid), let isAvailable = device["isAvailable"] as? Bool, let state = device["state"] as? String else { @@ -608,9 +620,25 @@ actor SimulatorPreviewService { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") process.arguments = arguments - process.environment = ProcessInfo.processInfo.environment.merging( - ["DEVELOPER_DIR": developerDir].merging(environment) { _, new in new } - ) { _, new in new } + // SECURITY: Don't forward the parent process's full environment + // into xcodebuild / simctl. xcodebuild then exposes that env to + // every build phase it runs, which would leak any developer ENV + // secrets ($OPENAI_API_KEY, $GITHUB_TOKEN, $AWS_*, etc.) into + // user-build phases of generated apps. Build a minimal allowlist + // and let callers add what they need via the `environment:` param. + let parent = ProcessInfo.processInfo.environment + var baseEnv: [String: String] = [ + "PATH": parent["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": parent["HOME"] ?? NSHomeDirectory(), + "USER": parent["USER"] ?? NSUserName(), + "LOGNAME": parent["LOGNAME"] ?? NSUserName(), + "TMPDIR": parent["TMPDIR"] ?? NSTemporaryDirectory(), + "LANG": parent["LANG"] ?? "en_US.UTF-8", + "LC_ALL": parent["LC_ALL"] ?? "en_US.UTF-8", + "DEVELOPER_DIR": developerDir, + ] + if let shell = parent["SHELL"] { baseEnv["SHELL"] = shell } + process.environment = baseEnv.merging(environment) { _, new in new } process.standardInput = FileHandle.nullDevice let stdoutPipe = Pipe() diff --git a/10x-macos/Services/XcodePreviewService.swift b/10x-macos/Services/XcodePreviewService.swift index bc2e085..d52462b 100644 --- a/10x-macos/Services/XcodePreviewService.swift +++ b/10x-macos/Services/XcodePreviewService.swift @@ -1,5 +1,6 @@ import Foundation import AppKit +import CryptoKit struct CompileResult: Sendable { let success: Bool @@ -645,6 +646,35 @@ actor XcodePreviewService { CoordinatedFileWriter.write(guide.markdown, to: guideURL) } + /// Verify the bundled xcodegen Mach-O against the pinned SHA-256 in + /// `xcodegen.sha256`. The binary is adhoc-signed (no Developer ID), + /// so without this check, a swap-and-commit attack against the + /// repository would land unnoticed and yield arbitrary code execution + /// during project generation. + fileprivate static func verifyXcodeGenIntegrity(at url: URL, in bundle: Bundle) throws { + guard let pinURL = bundle.url(forResource: "xcodegen", withExtension: "sha256") else { + throw XcodeGenError.integrityPinMissing + } + let pinContents = try String(contentsOf: pinURL, encoding: .utf8) + // Accept either bare-hash or ` filename` shasum-style format. + let expected = pinContents + .split(whereSeparator: { $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() ?? "" + guard expected.count == 64 else { + throw XcodeGenError.integrityPinMissing + } + + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let digest = SHA256.hash(data: data) + let actual = digest.map { String(format: "%02x", $0) }.joined() + + guard actual == expected else { + throw XcodeGenError.integrityCheckFailed(expected: expected, actual: actual) + } + } + /// Generate a proper .xcodeproj using the bundled XcodeGen binary. private func generateXcodeProject( iosDir: URL, @@ -676,6 +706,13 @@ actor XcodePreviewService { throw XcodeGenError.binaryNotFound } + // SECURITY: verify the bundled xcodegen Mach-O against a pinned + // SHA-256 before invoking it. The binary is adhoc-signed (no + // Developer ID), so a malicious contributor can otherwise swap it + // with an opaque diff and the change would land unnoticed. The + // pinned hash lives next to the binary as `xcodegen.sha256`. + try Self.verifyXcodeGenIntegrity(at: xcodegen, in: resourceBundle) + let process = Process() process.executableURL = xcodegen process.arguments = ["generate", "--spec", "project.yml", "--no-env"] @@ -1453,7 +1490,37 @@ actor XcodePreviewService { } private static func yamlSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "''") + "'" + // SECURITY: even with proper single-quote escaping, control bytes, + // line breaks, NUL, or unbalanced multi-byte sequences in a + // model-supplied identifier can produce malformed YAML that + // xcodegen interprets differently than intended. Strip any + // non-printable / line-break characters before quoting. + let sanitized = value.unicodeScalars + .filter { scalar in + let value = scalar.value + // Allow printable ASCII and common Unicode letters/digits. + // Reject NUL, control bytes, and line/paragraph separators. + if value < 0x20 || value == 0x7F { return false } + if value == 0x2028 || value == 0x2029 { return false } // LSEP / PSEP + return true + } + return "'" + String(String.UnicodeScalarView(sanitized)) + .replacingOccurrences(of: "'", with: "''") + "'" + } + + /// Validate a project display name supplied by the model. Reject names + /// that contain control characters or are unreasonably long; fall back + /// to a safe default if invalid. This is defense-in-depth on top of + /// `yamlSingleQuoted` — display names also flow into Info.plist and + /// xcodebuild scheme names where structural surprises are unwelcome. + static func validatedDisplayName(_ value: String, fallback: String = "App") -> String { + let pattern = #"^[A-Za-z0-9 \-_.'()]{1,64}$"# + return value.range(of: pattern, options: .regularExpression) != nil ? value : fallback + } + + static func validatedBundleIdentifier(_ value: String, fallback: String = "com.example.app") -> String { + let pattern = #"^[A-Za-z0-9.\-]{1,128}$"# + return value.range(of: pattern, options: .regularExpression) != nil ? value : fallback } private nonisolated static func requiredPackageDependencies( @@ -1607,12 +1674,18 @@ private struct SwiftPackageDependency { enum XcodeGenError: LocalizedError { case binaryNotFound + case integrityCheckFailed(expected: String, actual: String) + case integrityPinMissing case generationFailed(String) var errorDescription: String? { switch self { case .binaryNotFound: return "Bundled xcodegen binary not found in app bundle" + case .integrityCheckFailed(let expected, let actual): + return "Bundled xcodegen failed integrity check (expected \(expected), got \(actual)). Refusing to execute." + case .integrityPinMissing: + return "Bundled xcodegen.sha256 pin is missing. Refusing to execute." case .generationFailed(let output): return "XcodeGen failed: \(output)" } diff --git a/10x-macos/SparkleUpdater.swift b/10x-macos/SparkleUpdater.swift index 8868966..e28c834 100644 --- a/10x-macos/SparkleUpdater.swift +++ b/10x-macos/SparkleUpdater.swift @@ -91,6 +91,14 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr guard !hasActivated else { return } hasActivated = true + // SECURITY: refuse to start the updater if SUPublicEDKey is missing, + // empty, or still a build-variable placeholder. The integrity of + // every Sparkle update depends on this key being correctly embedded + // at build time. Crashing on launch is the right failure mode here: + // it makes a misconfigured release impossible to ship rather than + // silently shipping an app whose update channel is unsigned-friendly. + Self.assertSparklePublicKeyEmbedded() + updaterController.startUpdater() guard updater.automaticallyChecksForUpdates else { return } @@ -99,6 +107,43 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr updater.checkForUpdateInformation() } + /// Verify the SUPublicEDKey baked into Info.plist looks like a valid + /// 32-byte base64-encoded ed25519 public key. + private static func assertSparklePublicKeyEmbedded() { + guard let key = Bundle.main.infoDictionary?["SUPublicEDKey"] as? String else { + #if DEBUG + print("[Sparkle] SUPublicEDKey is missing — refusing to start updater.") + #else + preconditionFailure("SUPublicEDKey is missing from Info.plist. Update channel is unsigned-friendly. Refusing to launch.") + #endif + return + } + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + // Reject empty strings and unsubstituted build-variable placeholders. + guard !trimmed.isEmpty, !trimmed.hasPrefix("$(") else { + #if DEBUG + print("[Sparkle] SUPublicEDKey is empty or a build-variable placeholder — refusing to start updater.") + #else + preconditionFailure("SUPublicEDKey is empty or a build-variable placeholder. Update channel is unsigned-friendly. Refusing to launch.") + #endif + return + } + // Sparkle's Ed25519 public key is 32 bytes, base64-encoded == 44 chars + // (ending in '='). Be lenient on padding but require a 32-byte payload. + let padded: String = { + let remainder = trimmed.count % 4 + return remainder == 0 ? trimmed : trimmed + String(repeating: "=", count: 4 - remainder) + }() + guard let data = Data(base64Encoded: padded), data.count == 32 else { + #if DEBUG + print("[Sparkle] SUPublicEDKey is not a valid 32-byte base64 — refusing to start updater.") + #else + preconditionFailure("SUPublicEDKey is not a valid 32-byte base64 ed25519 public key. Refusing to launch.") + #endif + return + } + } + func checkForUpdates() { activate() @@ -121,7 +166,12 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr func openReleaseNotes() { guard let url = availableUpdatePrompt?.releaseNotesURL else { return } - NSWorkspace.shared.open(url) + // SECURITY: the appcast item's release-notes URL is signed only via + // the EdDSA signature on the DMG enclosure, not the appcast XML + // itself. A hijacked appcast can otherwise set this to + // file:///System/Library/CoreServices/Installer.app or a .terminal + // and turn the click into host RCE. Only allow http(s). + NSWorkspace.shared.openIfSafeWebURL(url) } func allowedChannels(for updater: SPUUpdater) -> Set { diff --git a/10x-macos/ViewModels/AuthManager.swift b/10x-macos/ViewModels/AuthManager.swift index dce4b0d..8c0462a 100644 --- a/10x-macos/ViewModels/AuthManager.swift +++ b/10x-macos/ViewModels/AuthManager.swift @@ -49,6 +49,21 @@ final class AuthManager { private static let startupRefreshTimeoutSeconds: Double = 8 private static let accessTokenRefreshLeewaySeconds: TimeInterval = 60 + /// SECURITY: dedicated ephemeral session for auth requests so that + /// auth-bearing traffic does not share `URLSession.shared`'s global + /// cookie jar, credential cache, or HTTP cache with arbitrary builder + /// requests. Cookies / cached credentials are explicitly disabled. + private static let authSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.httpCookieAcceptPolicy = .never + config.httpShouldSetCookies = false + config.urlCredentialStorage = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + return URLSession(configuration: config) + }() + var isAuthenticated = false var isCheckingAuth = false var activeSignInProvider: SignInProvider? @@ -85,8 +100,17 @@ final class AuthManager { if let saved = authTokenStore.string(for: tokenKey, allowUserInteraction: false), !saved.isEmpty { accessToken = saved refreshToken = authTokenStore.string(for: refreshTokenKey, allowUserInteraction: false) - userId = UserDefaults.standard.string(forKey: userIdKey) - userEmail = UserDefaults.standard.string(forKey: userEmailKey) + // SECURITY: read userId/email from Keychain instead of + // UserDefaults. UserDefaults is writable by sibling processes + // without App Sandbox; not a credential by itself, but a + // sibling process can otherwise rewrite the displayed user + // identity to confuse the UI. + userId = authTokenStore.string(for: userIdKey, allowUserInteraction: false) + userEmail = authTokenStore.string(for: userEmailKey, allowUserInteraction: false) + // Best-effort scrub of any legacy UserDefaults values from + // pre-Keychain builds. + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) isAuthenticated = true isCheckingAuth = true @@ -435,14 +459,16 @@ final class AuthManager { request.setValue(Config.supabaseAnonKey, forHTTPHeaderField: "apikey") do { - let (data, _) = try await URLSession.shared.data(for: request) + let (data, _) = try await Self.authSession.data(for: request) if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { let id = json["id"] as? String let email = json["email"] as? String userId = id userEmail = email - if let id { UserDefaults.standard.set(id, forKey: userIdKey) } - if let email { UserDefaults.standard.set(email, forKey: userEmailKey) } + if let id { authTokenStore.set(id, for: userIdKey) } + if let email { authTokenStore.set(email, for: userEmailKey) } + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) } } catch { print("[Auth] Failed to fetch user: \(error)") @@ -474,16 +500,21 @@ final class AuthManager { authTokenStore.set(accessToken, for: tokenKey) authTokenStore.set(refreshToken, for: refreshTokenKey) + // Persist the user identity in Keychain alongside the token rather + // than UserDefaults, which is writable by sibling processes when + // the App Sandbox is off. if let userId, !userId.isEmpty { - UserDefaults.standard.set(userId, forKey: userIdKey) + authTokenStore.set(userId, for: userIdKey) } else { - UserDefaults.standard.removeObject(forKey: userIdKey) + authTokenStore.set(nil, for: userIdKey) } if let userEmail, !userEmail.isEmpty { - UserDefaults.standard.set(userEmail, forKey: userEmailKey) + authTokenStore.set(userEmail, for: userEmailKey) } else { - UserDefaults.standard.removeObject(forKey: userEmailKey) + authTokenStore.set(nil, for: userEmailKey) } + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) if syncToSupabase { await syncSessionToSupabaseSDK( @@ -638,7 +669,7 @@ final class AuthManager { request.setValue(Config.supabaseAnonKey, forHTTPHeaderField: "apikey") request.httpBody = try JSONSerialization.data(withJSONObject: ["refresh_token": refreshToken]) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await Self.authSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -785,6 +816,8 @@ final class AuthManager { authTokenStore.remove(tokenKey) authTokenStore.remove(refreshTokenKey) + authTokenStore.remove(userIdKey) + authTokenStore.remove(userEmailKey) UserDefaults.standard.removeObject(forKey: userIdKey) UserDefaults.standard.removeObject(forKey: userEmailKey) } diff --git a/10x-macos/ViewModels/BillingViewModel.swift b/10x-macos/ViewModels/BillingViewModel.swift index 99699dc..6b5c708 100644 --- a/10x-macos/ViewModels/BillingViewModel.swift +++ b/10x-macos/ViewModels/BillingViewModel.swift @@ -24,6 +24,46 @@ final class BillingDeepLinkStore { } } +/// Outstanding nonces issued when the user clicks "Subscribe" / "Buy". +/// Required on every `app.10x.macos://billing/return` callback so an +/// external website can't drive the user's billing flow with an +/// attacker-chosen `session_id`. The corresponding nonce is passed to the +/// backend as Stripe's `client_reference_id` (or equivalent) and echoed +/// back in the return URL. +@MainActor +final class BillingDeepLinkNonceStore { + static let shared = BillingDeepLinkNonceStore() + + private var outstanding: Set = [] + + /// Issue a new nonce; caller forwards it to the backend's checkout + /// session creation as `client_reference_id`. + func issue() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let nonce: String + if status == errSecSuccess { + nonce = bytes.map { String(format: "%02x", $0) }.joined() + } else { + nonce = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + outstanding.insert(nonce) + return nonce + } + + /// One-shot validation. Returns true and removes the nonce if it was + /// outstanding; returns false otherwise. + func consume(_ nonce: String) -> Bool { + outstanding.remove(nonce) != nil + } + + /// Drop all outstanding nonces — call on sign-out so cross-account + /// reuse can never happen. + func clear() { + outstanding.removeAll() + } +} + enum BillingStatusKind { case info case success @@ -552,6 +592,19 @@ final class BillingViewModel { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let queryItems = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + + // SECURITY: refuse callbacks that don't carry a previously-issued + // one-time nonce. URL-scheme registration is global; without this + // gate, any website can drive the user's billing flow with an + // attacker-chosen session_id. The nonce is issued by + // BillingDeepLinkNonceStore.shared.issue() at checkout-start time + // and passed to the backend as client_reference_id; the backend + // echoes it back in the return URL. + guard let nonce = queryItems["nonce"], BillingDeepLinkNonceStore.shared.consume(nonce) else { + setStatus("Billing callback rejected (invalid or missing nonce). If you just completed checkout, please refresh manually.", kind: .error) + return + } + let source = queryItems["source"] ?? "checkout" let status = queryItems["status"] ?? "success" let action = queryItems["action"] ?? "subscription" @@ -770,7 +823,7 @@ final class BillingViewModel { guard let url = URL(string: rawURL) else { throw APIError.invalidURL } - let opened = NSWorkspace.shared.open(url) + let opened = NSWorkspace.shared.openIfSafeWebURL(url) if !opened { throw APIError.invalidResponse } diff --git a/10x-macos/ViewModels/BuilderViewModel+Generation.swift b/10x-macos/ViewModels/BuilderViewModel+Generation.swift index 4cadf9a..d76b78d 100644 --- a/10x-macos/ViewModels/BuilderViewModel+Generation.swift +++ b/10x-macos/ViewModels/BuilderViewModel+Generation.swift @@ -1704,7 +1704,7 @@ extension BuilderViewModel { throw SuperwallManagementServiceError.invalidInput("Link a Superwall application first.") } let opened = await MainActor.run { - NSWorkspace.shared.open(url) + NSWorkspace.shared.openIfSafeWebURL(url) } if opened { return "Opened the linked Superwall dashboard." @@ -1716,7 +1716,7 @@ extension BuilderViewModel { throw SuperwallManagementServiceError.invalidInput("Link a Superwall application first.") } let opened = await MainActor.run { - NSWorkspace.shared.open(url) + NSWorkspace.shared.openIfSafeWebURL(url) } if opened { return "Opened the linked Superwall paywalls." @@ -1728,7 +1728,7 @@ extension BuilderViewModel { throw SuperwallManagementServiceError.invalidInput("Link a Superwall application first.") } let opened = await MainActor.run { - NSWorkspace.shared.open(url) + NSWorkspace.shared.openIfSafeWebURL(url) } if opened { return "Opened the linked Superwall templates." diff --git a/10x-macos/Views/Auth/LoginView.swift b/10x-macos/Views/Auth/LoginView.swift index 1880308..40401ad 100644 --- a/10x-macos/Views/Auth/LoginView.swift +++ b/10x-macos/Views/Auth/LoginView.swift @@ -1968,7 +1968,7 @@ private enum LoginEnvironmentTooling { for rawURL in urls { guard let url = URL(string: rawURL) else { continue } - if NSWorkspace.shared.open(url) { + if NSWorkspace.shared.openIfSafeWebURL(url) { return true } } diff --git a/10x-macos/Views/Billing/BillingView.swift b/10x-macos/Views/Billing/BillingView.swift index d490397..38a0b25 100644 --- a/10x-macos/Views/Billing/BillingView.swift +++ b/10x-macos/Views/Billing/BillingView.swift @@ -470,7 +470,7 @@ struct BillingView: View { if let url = invoice.hostedInvoiceURL ?? invoice.invoicePDF { Button("Open") { guard let resolvedURL = URL(string: url) else { return } - NSWorkspace.shared.open(resolvedURL) + NSWorkspace.shared.openIfSafeWebURL(resolvedURL) } .buttonStyle(.plain) .font(Theme.geist(11, weight: .semibold)) diff --git a/10x-macos/Views/Preview/EnvironmentVariablesView.swift b/10x-macos/Views/Preview/EnvironmentVariablesView.swift index 904e12d..30716fa 100644 --- a/10x-macos/Views/Preview/EnvironmentVariablesView.swift +++ b/10x-macos/Views/Preview/EnvironmentVariablesView.swift @@ -3235,7 +3235,7 @@ struct EnvironmentVariablesView: View { supabaseManagementMessage = nil return } - guard NSWorkspace.shared.open(url) else { + guard NSWorkspace.shared.openIfSafeWebURL(url) else { supabaseManagementError = friendlySupabaseErrorMessage( for: APIError.invalidResponse, context: .openProject @@ -3690,7 +3690,7 @@ struct EnvironmentVariablesView: View { private func openSuperwallDashboard() { guard let url = linkedSuperwallDashboardURL, - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Link a Superwall application first.") return } @@ -3700,7 +3700,7 @@ struct EnvironmentVariablesView: View { private func openSuperwallPaywallsDashboard() { guard let url = linkedSuperwallPaywallsDashboardURL, - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Link a Superwall application first.") return } @@ -3710,7 +3710,7 @@ struct EnvironmentVariablesView: View { private func openSuperwallTemplatesDashboard() { guard let url = linkedSuperwallTemplatesDashboardURL, - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Link a Superwall application first.") return } @@ -3723,7 +3723,7 @@ struct EnvironmentVariablesView: View { guard let url = URL(string: trimmed), let host = url.host?.lowercased(), host.contains("superwall.com"), - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Enter a valid public Superwall paywall share link first.") return } diff --git a/10x-macosTests/AuthKeychainStoreTests.swift b/10x-macosTests/AuthKeychainStoreTests.swift index 784ef44..52fe87a 100644 --- a/10x-macosTests/AuthKeychainStoreTests.swift +++ b/10x-macosTests/AuthKeychainStoreTests.swift @@ -3,9 +3,16 @@ import XCTest @testable import TenXAppCore final class AuthKeychainStoreTests: XCTestCase { - func testAuthTokenStoreMigratesLegacyDefaultsIntoKeychain() { + /// SECURITY: legacy values in UserDefaults must NOT be silently + /// migrated into Keychain. Without App Sandbox the bundle's UserDefaults + /// plist is writable by sibling processes; the previous migration was + /// a session-fixation primitive (issue #9). The new contract is: + /// - return nil when no Keychain value is present + /// - scrub any leftover legacy UserDefaults value (so an attacker + /// can't trigger the migration by clearing the Keychain entry) + func testAuthTokenStoreDoesNotMigrateLegacyDefaults() { let key = "tenx_access_token" - let suiteName = "AuthTokenStoreMigratesLegacyDefaultsIntoKeychain" + let suiteName = "AuthTokenStoreDoesNotMigrateLegacyDefaults" let service = "app.10x.tests.auth.tokens.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! defaults.set("legacy-token", forKey: key) @@ -17,9 +24,12 @@ final class AuthKeychainStoreTests: XCTestCase { let store = AuthTokenStore(service: service, userDefaultsSuiteName: suiteName) - XCTAssertEqual(store.string(for: key), "legacy-token") + // Legacy value must NOT be adopted as a credential. + XCTAssertNil(store.string(for: key)) + // Legacy value must be scrubbed. XCTAssertNil(defaults.string(forKey: key)) - XCTAssertEqual(AuthKeychainStore.string(for: key, service: service), "legacy-token") + // Keychain must remain empty (no silent migration). + XCTAssertNil(AuthKeychainStore.string(for: key, service: service)) } func testAuthTokenStoreRemovesKeychainValue() { @@ -54,9 +64,11 @@ final class AuthKeychainStoreTests: XCTestCase { XCTAssertTrue(store.hasValue(for: key, allowUserInteraction: false)) } - func testKeychainAuthLocalStorageMigratesLegacyUserDefaultsData() throws { + /// SECURITY: see testAuthTokenStoreDoesNotMigrateLegacyDefaults. + /// Same contract for KeychainAuthLocalStorage (Supabase session store). + func testKeychainAuthLocalStorageDoesNotMigrateLegacyUserDefaults() throws { let key = "supabase.session" - let suiteName = "KeychainAuthLocalStorageMigratesLegacyUserDefaultsData" + let suiteName = "KeychainAuthLocalStorageDoesNotMigrateLegacyUserDefaults" let service = "app.10x.tests.auth.supabase.\(UUID().uuidString)" let storage = KeychainAuthLocalStorage( service: service, @@ -73,11 +85,14 @@ final class AuthKeychainStoreTests: XCTestCase { defaults.removePersistentDomain(forName: suiteName) } - let migrated = try storage.retrieve(key: key) + let retrieved = try storage.retrieve(key: key) - XCTAssertEqual(migrated, legacyData) + // Legacy value must NOT be returned as the active session. + XCTAssertNil(retrieved) + // Legacy value must be scrubbed from UserDefaults. XCTAssertNil(defaults.data(forKey: legacyKey)) - XCTAssertEqual(AuthKeychainStore.data(for: key, service: service), legacyData) + // Keychain must remain empty (no silent migration). + XCTAssertNil(AuthKeychainStore.data(for: key, service: service)) } func testSupabaseManagementTokenStoreUsesKeychain() { diff --git a/AppInfo.plist b/AppInfo.plist index 9c3010b..39fd72a 100644 --- a/AppInfo.plist +++ b/AppInfo.plist @@ -3,7 +3,7 @@ ATSApplicationFontsPath - . + Fonts CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleName @@ -52,7 +52,7 @@ SUEnableAutomaticChecks SUAutomaticallyUpdate - + SUFeedURL $(SPARKLE_FEED_URL) SUPublicEDKey diff --git a/Package.swift b/Package.swift index 0029463..85328a5 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,10 @@ let package = Package( .executable(name: "10x-evals", targets: ["TenXEvals"]), ], dependencies: [ - .package(url: "https://github.com/jpsim/Yams.git", from: "6.0.1"), + // Pin Yams to an exact version (matching the rest of the deps below). + // `from:` allows any 6.x.y; with no Package.resolved committed, that + // means a future malicious 6.x release would silently land in builds. + .package(url: "https://github.com/jpsim/Yams.git", exact: "6.0.1"), .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.9.1"), // Keep Xcode's package scheme and the app project on the same crypto checkout. .package(url: "https://github.com/apple/swift-crypto.git", exact: "4.2.0"), @@ -62,6 +65,7 @@ let package = Package( ], resources: [ .copy("10x-macos/Resources/xcodegen"), + .copy("10x-macos/Resources/xcodegen.sha256"), ], swiftSettings: [ .unsafeFlags(["-enable-bare-slash-regex", "-enable-testing"]), diff --git a/README.md b/README.md index e8a0eea..f87c614 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 10x +# 10x - Security Reviewed by Al3D1N | CISSP, Security X, CCISO An AI-powered iOS app builder for macOS. Describe the app you want, and 10x generates production-quality SwiftUI code, scaffolds an Xcode project, and previews it on the iOS Simulator — all from a conversational interface. The shipped beta artifact is packaged as **10x.app**. diff --git a/scripts/release/create-dmg.sh b/scripts/release/create-dmg.sh index 2c34311..cb13bbf 100755 --- a/scripts/release/create-dmg.sh +++ b/scripts/release/create-dmg.sh @@ -171,4 +171,12 @@ hdiutil convert \ rm -f "$temp_dmg" +# SECURITY: pin the DMG bytes immediately after creation so notarize-dmg.sh +# can verify the artifact wasn't swapped between create and notarize. +# notarytool only attests to bytes-at-submission, so a CI step compromise +# (or a misordered pipeline) between create and submit would otherwise be +# undetected. +( cd "$(dirname "$DMG_PATH")" && shasum -a 256 "$(basename "$DMG_PATH")" > "$(basename "$DMG_PATH").sha256" ) +log "DMG hash pinned at $DMG_PATH.sha256" + log "DMG ready at $DMG_PATH" diff --git a/scripts/release/deploy-vercel.sh b/scripts/release/deploy-vercel.sh index 04c728a..1dd18bb 100755 --- a/scripts/release/deploy-vercel.sh +++ b/scripts/release/deploy-vercel.sh @@ -30,17 +30,20 @@ cat > "$site_root/.vercel/project.json" </dev/null ) \ + || fail "DMG integrity check failed against $dmg_pin — refusing to sign/notarize." + build_codesign_keychain_args log "Signing DMG with Developer ID identity" diff --git a/scripts/release/publish-release.sh b/scripts/release/publish-release.sh index d7661b2..ac95237 100755 --- a/scripts/release/publish-release.sh +++ b/scripts/release/publish-release.sh @@ -284,13 +284,20 @@ prepare_release_notes_source() { build_label="$APP_VERSION Beta $APP_BUILD" fi + # SECURITY: HTML-escape every dynamic value. A tagged release whose + # version string contains HTML/JS would otherwise XSS the release page. + esc_app_name="$(escape_html "$APP_NAME")" + esc_build_label="$(escape_html "$build_label")" + esc_published="$(escape_html "$published_at_human")" + esc_source_path="$(escape_html "$release_notes_source")" + cat > "$release_notes_source" < - ${APP_NAME} ${build_label} + ${esc_app_name} ${esc_build_label}