diff --git a/Scyther Playground/Scyther Playground.xcodeproj/project.pbxproj b/Scyther Playground/Scyther Playground.xcodeproj/project.pbxproj index c80c545f..8de5f7b6 100644 --- a/Scyther Playground/Scyther Playground.xcodeproj/project.pbxproj +++ b/Scyther Playground/Scyther Playground.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ CA9F8FCF25DE0B5F000FA677 /* BlueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9F8FCE25DE0B5F000FA677 /* BlueViewController.swift */; }; CA9F8FD425DE0EFF000FA677 /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9F8FD325DE0EFF000FA677 /* Button.swift */; }; CA9F8FD925DE0F6D000FA677 /* ButtonPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9F8FD825DE0F6D000FA677 /* ButtonPreviews.swift */; }; + CABAC83926F7542000BA8CFD /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABAC83826F7542000BA8CFD /* Keychain.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,6 +63,7 @@ CA9F8FD825DE0F6D000FA677 /* ButtonPreviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonPreviews.swift; sourceTree = ""; }; CA9F908025DFEA66000FA677 /* Scyther Playground.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Scyther Playground.entitlements"; sourceTree = ""; }; CAB7FAE225E3C9B300B5C7CA /* Scyther Playground-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Scyther Playground-Bridging-Header.h"; sourceTree = ""; }; + CABAC83826F7542000BA8CFD /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -121,6 +123,7 @@ CA9F8FCE25DE0B5F000FA677 /* BlueViewController.swift */, CA9F8FD325DE0EFF000FA677 /* Button.swift */, CA9F8FD825DE0F6D000FA677 /* ButtonPreviews.swift */, + CABAC83826F7542000BA8CFD /* Keychain.swift */, CA9F8FC125DE08E5000FA677 /* Environments.swift */, CA73FCED25D690D2007FA627 /* Main.storyboard */, CA73FCF025D690D2007FA627 /* Assets.xcassets */, @@ -291,6 +294,7 @@ CA9F8FC725DE0A88000FA677 /* RedViewController.swift in Sources */, CA73FCEC25D690D2007FA627 /* ViewController.swift in Sources */, CA9F8FC225DE08E5000FA677 /* Environments.swift in Sources */, + CABAC83926F7542000BA8CFD /* Keychain.swift in Sources */, CA73FCE825D690D2007FA627 /* AppDelegate.swift in Sources */, CA9F8FCF25DE0B5F000FA677 /* BlueViewController.swift in Sources */, CA73FCEA25D690D2007FA627 /* SceneDelegate.swift in Sources */, @@ -635,7 +639,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/bstillitano/Scyther.git"; requirement = { - branch = "bugfix/view-frames-on-load"; + branch = "feature/keychain-browser"; kind = branch; }; }; diff --git a/Scyther Playground/Scyther Playground.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Scyther Playground/Scyther Playground.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 09f2ad5c..88c08186 100644 --- a/Scyther Playground/Scyther Playground.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Scyther Playground/Scyther Playground.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "package": "Scyther", "repositoryURL": "https://github.com/bstillitano/Scyther.git", "state": { - "branch": "bugfix/view-frames-on-load", - "revision": "b6735940ae82ddca29930a4dbcc1f8fff8d32199", + "branch": "feature/keychain-browser", + "revision": "cd430860b96cb7f0e3efb7f648462a830408a32c", "version": null } }, diff --git a/Scyther Playground/Scyther Playground/Keychain.swift b/Scyther Playground/Scyther Playground/Keychain.swift new file mode 100644 index 00000000..f89d7dcb --- /dev/null +++ b/Scyther Playground/Scyther Playground/Keychain.swift @@ -0,0 +1,8 @@ +// +// Keychain.swift +// Scyther Playground +// +// Created by Brandon Stillitano on 19/9/21. +// + +import Foundation diff --git a/Scyther Playground/Scyther Playground/ViewController.swift b/Scyther Playground/Scyther Playground/ViewController.swift index d0922d50..e430909a 100644 --- a/Scyther Playground/Scyther Playground/ViewController.swift +++ b/Scyther Playground/Scyther Playground/ViewController.swift @@ -5,6 +5,7 @@ // Created by Brandon Stillitano on 12/2/21. // +import Security import Scyther import UIKit @@ -19,48 +20,6 @@ class ViewController: UIViewController { //Setup Interface setupUI() setupConstraints() - - //Setup Data - if let cookie = HTTPCookie(properties: [ - .domain: ".test.scyther.com", - .path: "/", - .name: "ScytherCookie", - .value: "K324klj23KLJKH223423CookieValueDSFLJ234", - .secure: "FALSE", - .discard: "TRUE" - ]) { - HTTPCookieStorage.shared.setCookie(cookie) - } - if let cookie = HTTPCookie(properties: [ - .domain: ".test.scyther.com", - .path: "/", - .name: "ScytherCookie2", - .value: "K324klj23KLJKH223423CookieValueDSFLJ234", - .secure: "FALSE", - .discard: "TRUE" - ]) { - HTTPCookieStorage.shared.setCookie(cookie) - } - if let cookie = HTTPCookie(properties: [ - .domain: ".test.scyther.com", - .path: "/", - .name: "ScytherCookie3", - .value: "K324klj23KLJKH223423CookieValueDSFLJ234", - .secure: "FALSE", - .discard: "TRUE" - ]) { - HTTPCookieStorage.shared.setCookie(cookie) - } - if let cookie = HTTPCookie(properties: [ - .domain: ".test.scyther.com", - .path: "/", - .name: "ScytherCookie4", - .value: "K324klj23KLJKH223423CookieValueDSFLJ234", - .secure: "FALSE", - .discard: "TRUE" - ]) { - HTTPCookieStorage.shared.setCookie(cookie) - } setupData() } @@ -112,11 +71,103 @@ extension ViewController: UITableViewDataSource { extension ViewController { func setupData() { + setupCookies() + setupKeychain() setupFlags() setupEnvironments() setupDeveloperTools() } + func setupCookies() { + if let cookie = HTTPCookie(properties: [ + .domain: ".test.scyther.com", + .path: "/", + .name: "ScytherCookie", + .value: "K324klj23KLJKH223423CookieValueDSFLJ234", + .secure: "FALSE", + .discard: "TRUE" + ]) { + HTTPCookieStorage.shared.setCookie(cookie) + } + if let cookie = HTTPCookie(properties: [ + .domain: ".test.scyther.com", + .path: "/", + .name: "ScytherCookie2", + .value: "K324klj23KLJKH223423CookieValueDSFLJ234", + .secure: "FALSE", + .discard: "TRUE" + ]) { + HTTPCookieStorage.shared.setCookie(cookie) + } + if let cookie = HTTPCookie(properties: [ + .domain: ".test.scyther.com", + .path: "/", + .name: "ScytherCookie3", + .value: "K324klj23KLJKH223423CookieValueDSFLJ234", + .secure: "FALSE", + .discard: "TRUE" + ]) { + HTTPCookieStorage.shared.setCookie(cookie) + } + if let cookie = HTTPCookie(properties: [ + .domain: ".test.scyther.com", + .path: "/", + .name: "ScytherCookie4", + .value: "K324klj23KLJKH223423CookieValueDSFLJ234", + .secure: "FALSE", + .discard: "TRUE" + ]) { + HTTPCookieStorage.shared.setCookie(cookie) + } + } + + func setupKeychain() { + //Clear Existing Keychain Items + let secItemClasses = [ + kSecClassGenericPassword, + kSecClassInternetPassword, + kSecClassCertificate, + kSecClassKey, + kSecClassIdentity + ] + for secItemClass in secItemClasses { + let dictionary = [kSecClass as String: secItemClass] + SecItemDelete(dictionary as CFDictionary) + } + + //Setup Generic Keychain + for i in 0...12 { + let username = "john" + let password = "69420".data(using: .utf8)! + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "\(username)+\(i)", + kSecValueData as String: password + ] + if SecItemAdd(attributes as CFDictionary, nil) == noErr { + print("User saved successfully in the keychain") + } else { + print("Something went wrong trying to save the user in the keychain") + } + } + + //Setup Internet Keychain + for i in 0...12 { + let username = "internet-boi" + let password = "1337-h4x0r".data(using: .utf8)! + let attributes: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: "\(username)+\(i)", + kSecValueData as String: password + ] + if SecItemAdd(attributes as CFDictionary, nil) == noErr { + print("User saved successfully in the keychain") + } else { + print("Something went wrong trying to save the user in the keychain") + } + } + } + func setupFlags() { var flags: [String: Bool] = [:] flags["logging"] = true diff --git a/Sources/Scyther/Extensions/NSDictionary+Extensions.swift b/Sources/Scyther/Extensions/NSDictionary+Extensions.swift new file mode 100644 index 00000000..6c4fffc6 --- /dev/null +++ b/Sources/Scyther/Extensions/NSDictionary+Extensions.swift @@ -0,0 +1,25 @@ +// +// File.swift +// +// +// Created by Brandon Stillitano on 20/9/21. +// + +import Foundation + +extension NSDictionary { + var swiftDictionary: Dictionary { + var swiftDictionary = Dictionary() + + for key: Any in self.allKeys { + guard let stringKey = key as? String else { + continue + } + if let keyValue = self.value(forKey: stringKey) { + swiftDictionary[stringKey] = keyValue + } + } + + return swiftDictionary + } +} diff --git a/Sources/Scyther/User Interface/Data Browser/DataBrowserViewController.swift b/Sources/Scyther/User Interface/Data Browser/DataBrowserViewController.swift new file mode 100644 index 00000000..b7723474 --- /dev/null +++ b/Sources/Scyther/User Interface/Data Browser/DataBrowserViewController.swift @@ -0,0 +1,154 @@ +// +// File.swift +// +// +// Created by Brandon Stillitano on 19/9/21. +// + +#if !os(macOS) +import UIKit + +internal class DataBrowserViewController: UIViewController { + // MARK: - Data + private let tableView = UITableView(frame: .zero, style: .insetGroupedSafe) + private var viewModel: DataBrowserViewModel = DataBrowserViewModel() + + // MARK: - Init + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + + setupUI() + setupConstraints() + setupData() + } + + convenience init(data: [String: [String: Any]]) { + self.init(nibName: nil, bundle: nil) + self.viewModel.data = data + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + private func setupUI() { + //Setup Table View + tableView.delegate = self + tableView.dataSource = self + view.addSubview(tableView) + + //Register Table View Cells + tableView.register(DefaultCell.self, forCellReuseIdentifier: RowStyle.default.rawValue) + tableView.register(SubtitleCell.self, forCellReuseIdentifier: RowStyle.subtitle.rawValue) + tableView.register(ButtonCell.self, forCellReuseIdentifier: RowStyle.button.rawValue) + tableView.register(EmptyCell.self, forCellReuseIdentifier: RowStyle.emptyRow.rawValue) + } + + private func setupConstraints() { + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[subview]-0-|", + options: .directionLeadingToTrailing, + metrics: nil, + views: ["subview": tableView])) + view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[subview]-0-|", + options: .directionLeadingToTrailing, + metrics: nil, + views: ["subview": tableView])) + } + + @objc + private func setupData() { + self.viewModel.delegate = self + self.viewModel.prepareObjects() + + title = viewModel.title + navigationItem.title = viewModel.title + } + + // MARK: - Lifecycle + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + UIView.setAnimationsEnabled(false) + UIDevice.current.setValue(UIDeviceOrientation.portrait.rawValue, forKey: "orientation") + UIView.setAnimationsEnabled(true) + } +} + +extension DataBrowserViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.numberOfSections + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return viewModel.title(forSection: section) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.numberOfRows(inSection: section) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + // Check for Cell + guard let row = viewModel.row(at: indexPath) else { + return UITableViewCell() + } + + // Setup Cell + let cell = tableView.dequeueReusableCell(withIdentifier: row.cellReuseIdentifier, + for: indexPath) + cell.textLabel?.text = viewModel.title(for: row, indexPath: indexPath) + cell.detailTextLabel?.text = row.detailText + cell.accessoryView = row.accessoryView + cell.accessoryType = row.accessoryType ?? .none + cell.detailTextLabel?.adjustsFontSizeToFitWidth = false + + return cell + } + +} + +extension DataBrowserViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // Deselect Cell + defer { tableView.deselectRow(at: indexPath, animated: true) } + + // Check for Cell + guard let row = viewModel.row(at: indexPath) else { + return + } + row.actionBlock?() + } + + func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool { + guard let row = viewModel.row(at: indexPath) else { return false } + return row.style != .button || row.style != .emptyRow + } + + func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool { + return (action == #selector(copy(_:))) + } + + func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) { + if action == #selector(copy(_:)) { + guard let cell = tableView.cellForRow(at: indexPath), let key = cell.textLabel?.text else { return } + UIPasteboard.general.string = "\(key): \(cell.detailTextLabel?.text ?? "")" + } + } +} + +extension DataBrowserViewController: DataBrowserViewModelProtocol { + func viewModelShouldReloadData() { + self.tableView.reloadData() + } + + func viewModel(viewModel: DataBrowserViewModel?, shouldShowViewController viewController: UIViewController?) { + guard let viewController = viewController else { + return + } + self.navigationController?.pushViewController(viewController, animated: true) + } +} +#endif diff --git a/Sources/Scyther/User Interface/Data Browser/DataBrowserViewModel.swift b/Sources/Scyther/User Interface/Data Browser/DataBrowserViewModel.swift new file mode 100644 index 00000000..4362c4d1 --- /dev/null +++ b/Sources/Scyther/User Interface/Data Browser/DataBrowserViewModel.swift @@ -0,0 +1,176 @@ +// +// File.swift +// +// +// Created by Brandon Stillitano on 19/9/21. +// + +#if !os(macOS) +import UIKit + +internal protocol DataBrowserViewModelProtocol: AnyObject { + func viewModelShouldReloadData() + func viewModel(viewModel: DataBrowserViewModel?, shouldShowViewController viewController: UIViewController?) +} + +internal class DataBrowserViewModel { + // MARK: - Data + private var sections: [Section] = [] + internal var data: [String: [String: Any]] = [:] { + didSet { + prepareObjects() + } + } + + // MARK: - Delegate + weak var delegate: DataBrowserViewModelProtocol? + + /// Single row representing a single value and key + func defaultRow(name: String?, value: String?, actionBlock: ActionBlock? = nil) -> DefaultRow { + let row: DefaultRow = DefaultRow() + row.text = name + row.detailText = value + row.actionBlock = actionBlock + row.accessoryType = actionBlock == nil ? .none : .disclosureIndicator + return row + } + + /// Single row representing a single value and key + func subtitleRow(name: String?, value: String?, actionBlock: ActionBlock? = nil) -> SubtitleRow { + let row: SubtitleRow = SubtitleRow() + row.text = name + row.detailText = value + row.actionBlock = actionBlock + row.accessoryType = actionBlock == nil ? .none : .disclosureIndicator + return row + } + + /// Empty row that contains text in a 'disabled' style + func emptyRow(text: String) -> EmptyRow { + var row: EmptyRow = EmptyRow() + row.text = text + + return row + } + + func prepareObjects() { + //Clear Data + sections.removeAll() + + //Setup Sections + for value in data { + var section: Section = Section() + section.title = value.key + let dataRows: [Row] = value.value.map { object in + let dataRow: DataRow = DataRow(title: object.key, from: object.value) + return objectFor(dataRow) + } + section.rows.append(contentsOf: dataRows) + if section.rows.isEmpty { + section.rows.append(emptyRow(text: "No \(value.key)")) + } + sections.append(section) + } + + //Call Delegate + delegate?.viewModelShouldReloadData() + } + + private func objectFor(_ dataRow: DataRow) -> Row { + switch dataRow { + case .array(let title, let arrayData): + var organisedData: [String: [String: Any]] = [:] + var subData: [String: Any] = [:] + arrayData.enumerated().forEach({ (index, element) in + subData["\(index)"] = element + }) + organisedData["Array Data"] = subData + return defaultRow(name: title, value: "Array") { [weak self] in + let viewController: DataBrowserViewController = DataBrowserViewController(data: organisedData) + self?.delegate?.viewModel(viewModel: self, shouldShowViewController: viewController) + } + + case .dictionary(let title, let dictionaryData): + let organisedData: [String: [String: Any]] = [ + "Dictionary Data": dictionaryData + ] + return defaultRow(name: title, value: "Dictionary") { [weak self] in + let viewController: DataBrowserViewController = DataBrowserViewController(data: organisedData) + self?.delegate?.viewModel(viewModel: self, shouldShowViewController: viewController) + } + + case .json(let title, let jsonData): + if let arrayData = jsonData as? NSArray { + var organisedData: [String: [String: Any]] = [:] + var subData: [String: Any] = [:] + arrayData.enumerated().forEach({ (index, element) in + subData["\(index)"] = element + }) + organisedData["Array Data"] = subData + return defaultRow(name: title, value: "Array") { [weak self] in + let viewController: DataBrowserViewController = DataBrowserViewController(data: organisedData) + self?.delegate?.viewModel(viewModel: self, shouldShowViewController: viewController) + } + } else if let dictionaryData = jsonData as? NSDictionary { + let organisedData: [String: [String: Any]] = [ + "Dictionary Data": dictionaryData.swiftDictionary + ] + return defaultRow(name: title, value: "Dictionary") { [weak self] in + let viewController: DataBrowserViewController = DataBrowserViewController(data: organisedData) + self?.delegate?.viewModel(viewModel: self, shouldShowViewController: viewController) + } + } else { + return subtitleRow(name: title, value: String(describing: jsonData)) + } + + case .string(let title, let stringData): + return defaultRow(name: title, value: stringData) + } + } +} + +// MARK: - Public data accessors +extension DataBrowserViewModel { + var title: String { + return "Data Browser" + } + + var numberOfSections: Int { + return sections.count + } + + func title(forSection index: Int) -> String? { + return sections[index].title + } + + func numberOfRows(inSection index: Int) -> Int { + return rows(inSection: index)?.count ?? 0 + } + + internal func row(at indexPath: IndexPath) -> Row? { + guard let rows = rows(inSection: indexPath.section) else { return nil } + guard rows.indices.contains(indexPath.row) else { return nil } + return rows[indexPath.row] + } + + func title(for row: Row, indexPath: IndexPath) -> String? { + return row.text + } + + func performAction(for row: Row, indexPath: IndexPath) { + row.actionBlock?() + } +} + +// MARK: - Private data accessors +extension DataBrowserViewModel { + private func section(for index: Int) -> Section? { + return sections[index] + } + + private func rows(inSection index: Int) -> [Row]? { + guard let section = section(for: index) else { return nil } + return section.rows.filter { !$0.isHidden } + } +} +#endif diff --git a/Sources/Scyther/User Interface/Data Browser/DataRow.swift b/Sources/Scyther/User Interface/Data Browser/DataRow.swift new file mode 100644 index 00000000..c0664b63 --- /dev/null +++ b/Sources/Scyther/User Interface/Data Browser/DataRow.swift @@ -0,0 +1,50 @@ +// +// File.swift +// +// +// Created by Brandon Stillitano on 19/9/21. +// + +import Foundation + +/// Internal enum used to represent different types of presentable data +internal enum DataRow { + case string(title: String?, data: String?) + case json(title: String?, data: Any) + case array(title: String?, data: [String]) + case dictionary(title: String?, data: [String: AnyObject]) + + init(title: String?, from input: Any) { + if let inputData = input as? Data, JSONSerialization.isValidJSONObject(inputData) { + self = .json(title: title, data: inputData) + } else if let inputData = input as? String { + if let data = inputData.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []) { + self = .json(title: title, data: json) + } else { + self = .string(title: title, data: inputData) + } + } else if let inputData = input as? Bool { + self = .string(title: title, data: inputData.stringValue) + } else if let inputData = input as? NSNumber { + self = .string(title: title, data: "\(inputData)") + } else if let inputData = input as? Date { + self = .string(title: title, data: inputData.formatted()) + } else if let inputData = input as? [String] { + self = .array(title: title, data: inputData) + } else if let inputData = input as? [Data] { + let certificates: [SecCertificate] = inputData.compactMap { SecCertificateCreateWithData(kCFAllocatorDefault, $0 as CFData) } + if certificates.count > 1 { + let certificateNames: [CFString] = certificates.compactMap { var name: CFString?; _ = SecCertificateCopyCommonName($0, &name); return name } + self = .array(title: title, data: certificateNames.compactMap { String($0) }) + } else { + self = .array(title: title, data: inputData.compactMap { String(data: $0, encoding: .utf8) }) + } + } else if let inputData = input as? [String: AnyObject] { + self = .dictionary(title: title, data: inputData) + } else if input is NSNull { + self = .string(title: title, data: "null") + } else { + self = .string(title: title, data: "Unsupported (\(type(of: input)))") + } + } +} diff --git a/Sources/Scyther/User Interface/Feature Flags/FeatureFlagsViewModel.swift b/Sources/Scyther/User Interface/Feature Flags/FeatureFlagsViewModel.swift index caa2c929..f774d5db 100644 --- a/Sources/Scyther/User Interface/Feature Flags/FeatureFlagsViewModel.swift +++ b/Sources/Scyther/User Interface/Feature Flags/FeatureFlagsViewModel.swift @@ -59,7 +59,7 @@ internal class FeatureFlagsViewModel { row.detailText = "Remote value: \(Toggler.instance.remoteValue(forToggle: name).stringValue)" // Setup Accessory let switchView = UIActionSwitch() - switchView.isOn = Toggler.instance.localValue(forToggle: name) + switchView.isOn = Toggler.instance.localValue(forToggle: name) ?? Toggler.instance.remoteValue(forToggle: name) switchView.actionBlock = { Toggler.instance.setLocalValue(value: switchView.isOn, forToggleWithName: name) } diff --git a/Sources/Scyther/User Interface/Menu/MenuViewModel.swift b/Sources/Scyther/User Interface/Menu/MenuViewModel.swift index 38213798..6ceb6282 100644 --- a/Sources/Scyther/User Interface/Menu/MenuViewModel.swift +++ b/Sources/Scyther/User Interface/Menu/MenuViewModel.swift @@ -176,7 +176,11 @@ internal class MenuViewModel { /// Setup Security Section var securitySection: Section = Section() securitySection.title = "Security" - securitySection.rows.append(emptyRow(text: "Coming soon")) + securitySection.rows.append(actionRow(name: "Keychain Browser", + icon: UIImage(systemImage: "key"), + actionBlock: { [weak self] in + self?.delegate?.viewModel(viewModel: self, shouldShowViewController: DataBrowserViewController(data: KeychainBrowser.keychainItems)) + })) /// Setup Support Section var supportSection: Section = Section() diff --git a/Sources/Scyther/User Interface/Network Logger/Log Details View Controller/LogDetailsViewModel.swift b/Sources/Scyther/User Interface/Network Logger/Log Details View Controller/LogDetailsViewModel.swift index 7a77390e..5d092b27 100644 --- a/Sources/Scyther/User Interface/Network Logger/Log Details View Controller/LogDetailsViewModel.swift +++ b/Sources/Scyther/User Interface/Network Logger/Log Details View Controller/LogDetailsViewModel.swift @@ -80,7 +80,7 @@ internal class LogDetailsViewModel { /// Button item for opening a view controller that contains a UILabel with the response body set var viewResponseButtonRow: ButtonRow { var row: ButtonRow = ButtonRow() - row.text = "View reponse body" + row.text = "View response body" row.actionBlock = { [weak self] in let viewController: TextReaderViewController = TextReaderViewController() viewController.title = "Response body" @@ -91,6 +91,19 @@ internal class LogDetailsViewModel { return row } + /// Button for opening a data browser for the response body + var browseResponseButtonRow: ButtonRow { + var row: ButtonRow = ButtonRow() + row.text = "Browse response body" + row.actionBlock = { [weak self] in + let viewController: DataBrowserViewController = DataBrowserViewController(data: self?.httpModel?.getResponseBodyDictionary() ?? [:]) + viewController.title = "Response body" + self?.delegate?.viewModel(viewModel: self, shouldShowViewController: viewController) + } + + return row + } + /// Empty row that contains text in a 'disabled' style func emptyRow(text: String) -> EmptyRow { var row: EmptyRow = EmptyRow() @@ -157,6 +170,7 @@ internal class LogDetailsViewModel { if String(httpModel?.getResponseBody() ?? "").isEmpty { responseBodySection.rows.append(emptyRow(text: "No data received")) } else { + responseBodySection.rows.append(browseResponseButtonRow) responseBodySection.rows.append(viewResponseButtonRow) } diff --git a/Sources/Scyther/Utilities/Interface Tookit/InterfaceToolkit.swift b/Sources/Scyther/Utilities/Interface Tookit/InterfaceToolkit.swift index 61148127..b6aed905 100644 --- a/Sources/Scyther/Utilities/Interface Tookit/InterfaceToolkit.swift +++ b/Sources/Scyther/Utilities/Interface Tookit/InterfaceToolkit.swift @@ -62,7 +62,9 @@ public class InterfaceToolkit: NSObject { self?.setupTopLevelViewsWrapper() self?.setupGridOverlay() self?.setWindowSpeed() - self?.swizzleLayout() + if self?.showsViewBorders ?? false { + self?.swizzleLayout() + } } } diff --git a/Sources/Scyther/Utilities/KeychainBrowser/KeychainBrowser.swift b/Sources/Scyther/Utilities/KeychainBrowser/KeychainBrowser.swift new file mode 100644 index 00000000..c0164f30 --- /dev/null +++ b/Sources/Scyther/Utilities/KeychainBrowser/KeychainBrowser.swift @@ -0,0 +1,68 @@ +// +// File.swift +// +// +// Created by Brandon Stillitano on 19/9/21. +// + +import Foundation + +/// Utility `struct` used to iterate the Keychain and return all values accessible by the consuming app. +internal struct KeychainBrowser { + /// Creates a dictionary of keychain values of differing types + /// - Returns: A dictionary containing kSecClassGenericPassword, kSecClassInternetPassword & kSecClassIdentity values + static var keychainItems: [String: [String: Any]] { + var values: [String: [String: Any]] = [:] + values["Generic Passwords"] = keychainItems(forClass: kSecClassGenericPassword) + values["Internet Passwords"] = keychainItems(forClass: kSecClassInternetPassword) + values["Identities"] = keychainItems(forClass: kSecClassIdentity) + return values + } + + static private func keychainItems(forClass secClass: CFString) -> [String: AnyObject] { + // Construct Keychain query + let query: [String: Any] = [ + kSecClass as String: secClass, + kSecReturnData as String: true, + kSecReturnAttributes as String: true, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + // Prepare data + var result: AnyObject? + let lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + // Check result code and pass back array of values as a dictionary + var values: [String: AnyObject] = [:] + if lastResultCode == noErr, let array = result as? [[String: Any]] { + array.forEach { + if let key = $0[kSecAttrAccount as String] as? String, let value = $0[kSecValueData as String] as? Data { + values[key] = String(data: value, encoding: .utf8) as AnyObject? + } else if let key = $0[kSecAttrLabel as String] as? String, let value = $0[kSecValueRef as String] { + values[key] = value as AnyObject + } + } + } + + return values + } + + /// USE WITH CAUTION - Deletes all items stored in the Keychain for the consuming app. + static internal func clearKeychain() { + let secItemClasses = [ + kSecClassGenericPassword, + kSecClassInternetPassword, + kSecClassCertificate, + kSecClassKey, + kSecClassIdentity + ] + + for secItemClass in secItemClasses { + let dictionary = [kSecClass as String: secItemClass] + SecItemDelete(dictionary as CFDictionary) + } + } +} diff --git a/Sources/Scyther/Utilities/Mocker/Mocker.swift b/Sources/Scyther/Utilities/Mocker/Mocker.swift new file mode 100644 index 00000000..48d6afa0 --- /dev/null +++ b/Sources/Scyther/Utilities/Mocker/Mocker.swift @@ -0,0 +1,63 @@ +// +// File.swift +// +// +// Created by Brandon Stillitano on 20/9/21. +// + +import Foundation + +internal enum Mocker { + static var nestedDictionaryData: [String: [String: Any]] { + let basicDictionary: [String: Any] = [ + "DictionaryString": "String", + "DictionaryInt": 1, + "DictionaryBool": true + ] + return [ + "Dictionary Data": [ + "String": "Stringy", + "Int": 69, + "Bool": true, + "String Array": ["String1", "String2", "String3"], + "Dictionary Array": [basicDictionary, basicDictionary], + "Nested Dictionary": [ + "String": "Stringy", + "Int": 420, + "Bool": true, + "StringArray": ["String1", "String2", "String3"], + ] + ], + "JSON Data": [ + "JSON String": Self.jsonString + ] + ] + } + + static var jsonString: String { + """ + { + "glossary": { + "title": "example glossary", + "GlossDiv": { + "title": "S", + "GlossList": { + "GlossEntry": { + "ID": "SGML", + "SortAs": "SGML", + "GlossTerm": "Standard Generalized Markup Language", + "Acronym": "SGML", + "Abbrev": "ISO 8879:1986", + "GlossDef": { + "para": "A meta-markup language, used to create markup languages such as DocBook.", + "GlossSeeAlso": ["GML", "XML"] + }, + "GlossSee": "markup" + } + } + } + } + } + """ + } +} diff --git a/Sources/Scyther/Utilities/Network Logger/LoggerHTTPModel.swift b/Sources/Scyther/Utilities/Network Logger/LoggerHTTPModel.swift index d30fa941..533d8d85 100644 --- a/Sources/Scyther/Utilities/Network Logger/LoggerHTTPModel.swift +++ b/Sources/Scyther/Utilities/Network Logger/LoggerHTTPModel.swift @@ -193,6 +193,18 @@ fileprivate func < (lhs: T?, rhs: T?) -> Bool { return prettyOutput(data, contentType: responseType) } + + @objc public func getResponseBodyDictionary() -> [String: [String: Any]] { + guard let data = readRawData(getResponseBodyFilepath()) else { + return [:] + } + + return [ + "Network Response": [ + "Body": prettyOutput(data, contentType: responseType) + ] + ] + } @objc public func getRandomHash() -> NSString { if !(self.randomHash != nil) { diff --git a/Sources/Scyther/Utilities/Toggler/Toggler.swift b/Sources/Scyther/Utilities/Toggler/Toggler.swift index 133a6a0b..f46d6e6a 100644 --- a/Sources/Scyther/Utilities/Toggler/Toggler.swift +++ b/Sources/Scyther/Utilities/Toggler/Toggler.swift @@ -79,10 +79,10 @@ public class Toggler { - Complexity: O(*n*) where *n* is the first index of `name` in the array of toggles. */ - internal func localValue(forToggle name: String) -> Bool { + internal func localValue(forToggle name: String) -> Bool? { /// Check for toggle in local array guard let toggle = toggles.first(where: { $0.name == name }) else { - return false + return nil } /// Check for local value