Repository: stelabouras/privacy-manifest Branch: main Commit: 587b03ed28a4 Files: 13 Total size: 85.2 KB Directory structure: gitextract_3gviv89a/ ├── .gitignore ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md └── Sources/ └── PrivacyManifest/ ├── Constants.swift ├── DirectoryProjectParser.swift ├── ProjectParser.swift ├── SpinnerStreams.swift ├── SwiftPackageProjectParser.swift ├── XcodeProjectParser.swift └── main.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store /.build /Packages /*.xcodeproj xcuserdata/ .swiftpm/ ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ install: swift build -c release install .build/release/privacy-manifest /usr/local/bin/privacy-manifest ================================================ FILE: Package.resolved ================================================ { "pins" : [ { "identity" : "aexml", "kind" : "remoteSourceControl", "location" : "https://github.com/tadija/AEXML.git", "state" : { "revision" : "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", "version" : "4.6.1" } }, { "identity" : "bluesignals", "kind" : "remoteSourceControl", "location" : "https://github.com/IBM-Swift/BlueSignals.git", "state" : { "revision" : "1f6c49e186c8a4eeef87ba14f2f97b8646559d13", "version" : "1.0.200" } }, { "identity" : "nanoseconds", "kind" : "remoteSourceControl", "location" : "https://github.com/dominicegginton/Nanoseconds", "state" : { "revision" : "34318d7a13b5b5013102fbe18b36b80368b4dcbd", "version" : "1.1.2" } }, { "identity" : "pathkit", "kind" : "remoteSourceControl", "location" : "https://github.com/kylef/PathKit.git", "state" : { "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", "version" : "1.0.1" } }, { "identity" : "rainbow", "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Rainbow", "state" : { "revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3", "version" : "4.0.1" } }, { "identity" : "spectre", "kind" : "remoteSourceControl", "location" : "https://github.com/kylef/Spectre.git", "state" : { "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", "version" : "0.10.1" } }, { "identity" : "spinner", "kind" : "remoteSourceControl", "location" : "https://github.com/dominicegginton/Spinner", "state" : { "revision" : "16ac0c404320005936579f1d6454bb88dbd8d71a", "version" : "2.1.0" } }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", "version" : "1.2.3" } }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { "revision" : "c7e239b5c1492ffc3ebd7fbcc7a92548ce4e78f0", "version" : "1.1.0" } }, { "identity" : "swift-certificates", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { "revision" : "01d7664523af5c169f26038f1e5d444ce47ae5ff", "version" : "1.0.1" } }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", "version" : "1.0.6" } }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { "revision" : "629f0b679d0fd0a6ae823d7f750b9ab032c00b80", "version" : "3.0.0" } }, { "identity" : "swift-driver", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-driver.git", "state" : { "branch" : "release/5.10", "revision" : "46bd60c4934aa8512061b8182f59dcc5f0a25fd0" } }, { "identity" : "swift-llbuild", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-llbuild.git", "state" : { "branch" : "release/5.10", "revision" : "fd7c2e0d9279edd023ced6b0a590f8407f5472f9" } }, { "identity" : "swift-package-manager", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-package-manager", "state" : { "branch" : "release/5.10", "revision" : "a0e7a8aef2989e315d0fe2180a5cbe2b9c8dc150" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574", "version" : "1.1.1" } }, { "identity" : "swift-tools-support-core", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-tools-support-core.git", "state" : { "branch" : "release/5.10", "revision" : "3695ee46daf7604bec9e16337a60aa62cd261774" } }, { "identity" : "xcodeproj", "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/XcodeProj.git", "state" : { "revision" : "313aaf1ad612135b7b0ccf731c86b5c07bf149b5", "version" : "8.20.0" } }, { "identity" : "yams", "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", "version" : "5.0.6" } } ], "version" : 2 } ================================================ FILE: Package.swift ================================================ // swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "PrivacyManifest", platforms: [ .macOS(.v13) ], products: [ .executable(name: "privacy-manifest", targets: ["PrivacyManifest"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.2.3"), .package(url: "https://github.com/apple/swift-package-manager", branch: "release/5.10"), .package(url: "https://github.com/tuist/XcodeProj", exact: "8.20.0"), .package(url: "https://github.com/dominicegginton/Spinner", exact: "2.1.0") ], targets: [ .executableTarget( name: "PrivacyManifest", dependencies: [ "XcodeProj", "Spinner", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SwiftPM-auto", package: "swift-package-manager"), ] ) ] ) ================================================ FILE: README.md ================================================ # Privacy Manifest

Privacy Manifest CLI tool parses an Xcode project/workspace or a Swift Package and attempts to locate calls to Apple's required reason APIs [^1] and detect privacy collected data frameworks [^2]. The tool detects and parses the source files of the project as well as the frameworks added in the Xcode project's Build Phase or in the Swift Package dependencies. It also detects any frameworks / static libraries and checks if they are included in the third-party SDK list that Apple has provided [^3]. The tool does not perform any sort of analysis beyond the simple line-by-line check for the existence of the method calls or symbols that Apple has already published. The tool uses a concurrent queue to speed up the parsing process. ## Installation You can either use the tool by typing: `swift run privacy-manifest` in the root directory of the project, or you can install the executable to `/usr/local/bin` directory so that you can call it from any folder. Check out the project and run the following command in the project root to install the binary to `/usr/local/bin`. ```sh sudo make install ``` ## Usage After installing the tool to the `/usr/local/bin` directory, you can invoke it from any directory using the following command: ``` privacy-manifest analyze --project path/to/project --reveal-occurrences --output path ``` The `path/to/project` can be a relative or an absolute path to the `.xcodeproj` or `Package.swift` file of your project. The `--reveal-occurrences` is an optional flag that displays extended information regarding the occurrences of the required reason APIs / privacy collected data frameworks in your codebase, highlighting the file and the line where a call has been detected. The `--output` flag is optional and if specified, a `PrivacyInfo.xcprivacy` property list file will be generated to that directory based on the detected required reason APIs and from the responses of the user. ## Example Below is the console output from the [VLC iOS OSS](https://github.com/videolan/vlc-ios). ![Privacy Manifest analyze running for VLC iOS project](https://raw.githubusercontent.com/stelabouras/privacy-manifest/main/.github/privacymanifest-vlc.gif) ## Future implementations The tool can output the occurrences report to HTML for better readability. On top of that, the list of third-party crash frameworks can be updated so that it can inform the user when such framework is detected (there is a related TODO in the code). ## Disclaimer Do not use this tool alone to create your privacy manifest file for your app or SDK. You must always double-check the occurrences that the tool displays as the tool does not know whether a certain occurrence is included in a comment or on an unused piece of code. Furthermore, there might also be cases where something has not been included in the parsing process. This tool gives you a high-level overview of the different required reason APIs and privacy collected data frameworks your project, workspace or package uses, so always do your own research after using this tool, to confirm the findings. ## License Licensed under Apache License 2.0, see [LICENSE](LICENSE) file. [^1]: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api [^2]: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests). [^3]: https://developer.apple.com/support/third-party-SDK-requirements/ ================================================ FILE: Sources/PrivacyManifest/Constants.swift ================================================ // // Constants.swift // // // Created by Stelios Petrakis on 14/4/24. // import Foundation struct CliSyntaxColor { static let WHITE_BOLD = "\u{001B}[0;1m" static let RED = "\u{001B}[0;0;31m" static let GREEN = "\u{001B}[0;32m" static let YELLOW = "\u{001B}[0;33m" static let BLUE = "\u{001B}[0;34m" static let MAGENTA = "\u{001B}[0;35m" static let CYAN = "\u{001B}[0;36m" static let PINK = "\u{001B}[0;91m" static let GREEN_BRIGHT = "\u{001B}[0;92m" static let YELLOW_BRIGHT = "\u{001B}[0;93m" static let BLUE_BRIGHT = "\u{001B}[0;94m" static let MAGENTA_BRIGHT = "\u{001B}[0;95m" static let CYAN_BRIGHT = "\u{001B}[0;96m" static let END = "\u{001B}[0;0m" } let PACKAGE_SWIFT_FILENAME = "Package.swift" let XCODE_PROJECT_PATH_EXTENSION = "xcodeproj" let XCODE_WORKSPACE_PATH_EXTENSION = "xcworkspace" enum RequiredReasonKey: CaseIterable, Comparable { case FILE_TIMESTAMP_APIS_KEY case SYSTEM_BOOT_APIS_KEY case DISK_SPACE_APIS_KEY case ACTIVE_KEYBOARD_APIS_KEY case USER_DEFAULTS_APIS_KEY case CORELOCATION_FRAMEWORK_KEY case HEALTHKIT_FRAMEWORK_KEY case CRASH_FRAMEWORK_KEY case CONTACTS_FRAMEWORK_KEY case THIRD_PARTY_SDK_KEY var description: String { switch self { case .FILE_TIMESTAMP_APIS_KEY: return "File Timestamp APIs" case .SYSTEM_BOOT_APIS_KEY: return "System boot time APIs" case .DISK_SPACE_APIS_KEY: return "Disk space APIs" case .ACTIVE_KEYBOARD_APIS_KEY: return "Active keyboard APIs" case .USER_DEFAULTS_APIS_KEY: return "User defaults APIs" case .CORELOCATION_FRAMEWORK_KEY: return "Core Location" case .HEALTHKIT_FRAMEWORK_KEY: return "HealthKit" case .CRASH_FRAMEWORK_KEY: return "Crash data" case .CONTACTS_FRAMEWORK_KEY: return "Contacts" case .THIRD_PARTY_SDK_KEY: return "Third-party SDKs" } } var privacyManifestKey: String? { switch self { case .FILE_TIMESTAMP_APIS_KEY: return "NSPrivacyAccessedAPICategoryFileTimestamp" case .SYSTEM_BOOT_APIS_KEY: return "NSPrivacyAccessedAPICategorySystemBootTime" case .DISK_SPACE_APIS_KEY: return "NSPrivacyAccessedAPICategoryDiskSpace" case .ACTIVE_KEYBOARD_APIS_KEY: return "NSPrivacyAccessedAPICategoryActiveKeyboards" case .USER_DEFAULTS_APIS_KEY: return "NSPrivacyAccessedAPICategoryUserDefaults" default: return nil } } var reasons: [String: String] { switch self { case .DISK_SPACE_APIS_KEY: return [ "85F4.1" : """ Declare this reason to display disk space information to the person using the device. Disk space may be displayed in units of information (such as bytes) or units of time combined with a media type (such as minutes of HD video). Information accessed for this reason, or any derived information, may not be sent off-device. There is an exception that allows the app to send disk space information over the local network to another device operated by the same person only for the purpose of displaying disk space information on that device; this exception only applies if the user has provided explicit permission to send disk space information, and the information may not be sent over the Internet. """, "E174.1" : """ Declare this reason to check whether there is sufficient disk space to write files, or to check whether the disk space is low so that the app can delete files when the disk space is low. The app must behave differently based on disk space in a way that is observable to users. Information accessed for this reason, or any derived information, may not be sent off-device. There is an exception that allows the app to avoid downloading files from a server when disk space is insufficient. """, "7D9E.1" : """ Declare this reason to include disk space information in an optional bug report that the person using the device chooses to submit. The disk space information must be prominently displayed to the person as part of the report. Information accessed for this reason, or any derived information, may be sent off-device only after the user affirmatively chooses to submit the specific bug report including disk space information, and only for the purpose of investigating or responding to the bug report. """, "B728.1" : """ Declare this reason if your app is a health research app, and you access this API category to detect and inform research participants about low disk space impacting the research data collection. Your app must comply with App Store Review Guideline §5.1.3. Your app must not offer any functionality other than providing information about and allowing people to participate in health research. """ ] case .FILE_TIMESTAMP_APIS_KEY: return [ "DDA9.1" : """ Declare this reason to display file timestamps to the person using the device. Information accessed for this reason, or any derived information, may not be sent off-device. """, "C617.1" : """ Declare this reason to access the timestamps, size, or other metadata of files inside the app container, app group container, or the app’s CloudKit container. """, "3B52.1" : """ Declare this reason to access the timestamps, size, or other metadata of files or directories that the user specifically granted access to, such as using a document picker view controller. """, "0A2A.1" : """ Declare this reason if your third-party SDK is providing a wrapper function around file timestamp API(s) for the app to use, and you only access the file timestamp APIs when the app calls your wrapper function. This reason may only be declared by third-party SDKs. This reason may not be declared if your third-party SDK was created primarily to wrap required reason API(s). Information accessed for this reason, or any derived information, may not be used for your third-party SDK’s own purposes or sent off-device by your third-party SDK. """ ] case .SYSTEM_BOOT_APIS_KEY: return [ "35F9.1" : """ Declare this reason to access the system boot time in order to measure the amount of time that has elapsed between events that occurred within the app or to perform calculations to enable timers. Information accessed for this reason, or any derived information, may not be sent off-device. There is an exception for information about the amount of time that has elapsed between events that occurred within the app, which may be sent off-device. """, "8FFB.1" : """ Declare this reason to access the system boot time to calculate absolute timestamps for events that occurred within your app, such as events related to the UIKit or AVFAudio frameworks. Absolute timestamps for events that occurred within your app may be sent off-device. System boot time accessed for this reason, or any other information derived from system boot time, may not be sent off-device. """, "3D61.1" : """ Declare this reason to include system boot time information in an optional bug report that the person using the device chooses to submit. The system boot time information must be prominently displayed to the person as part of the report. Information accessed for this reason, or any derived information, may be sent off-device only after the user affirmatively chooses to submit the specific bug report including system boot time information, and only for the purpose of investigating or responding to the bug report. """ ] case .ACTIVE_KEYBOARD_APIS_KEY: return [ "3EC4.1" : """ Declare this reason if your app is a custom keyboard app, and you access this API category to determine the keyboards that are active on the device. Providing a systemwide custom keyboard to the user must be the primary functionality of the app. Information accessed for this reason, or any derived information, may not be sent off-device. """, "54BD.1" : """ Declare this reason to access active keyboard information to present the correct customized user interface to the person using the device. The app must have text fields for entering or editing text and must behave differently based on active keyboards in a way that is observable to users. Information accessed for this reason, or any derived information, may not be sent off-device. """ ] case .USER_DEFAULTS_APIS_KEY: return [ "CA92.1" : """ Declare this reason to access user defaults to read and write information that is only accessible to the app itself. This reason does not permit reading information that was written by other apps or the system, or writing information that can be accessed by other apps. """, "1C8F.1" : """ Declare this reason to access user defaults to read and write information that is only accessible to the apps, app extensions, and App Clips that are members of the same App Group as the app itself. This reason does not permit reading information that was written by apps, app extensions, or App Clips outside the same App Group or by the system. Your app is not responsible if the system provides information from the global domain because a key is not present in your requested domain while your app is attempting to read information that apps, app extensions, or App Clips in your app’s App Group write. This reason also does not permit writing information that can be accessed by apps, app extensions, or App Clips outside the same App Group. """, "C56D.1" : """ Declare this reason if your third-party SDK is providing a wrapper function around user defaults API(s) for the app to use, and you only access the user defaults APIs when the app calls your wrapper function. This reason may only be declared by third-party SDKs. This reason may not be declared if your third-party SDK was created primarily to wrap required reason API(s). Information accessed for this reason, or any derived information, may not be used for your third-party SDK’s own purposes or sent off-device by your third-party SDK. """, "AC6B.1" : """ Declare this reason to access user defaults to read the com.apple.configuration.managed key to retrieve the managed app configuration set by MDM, or to set the com.apple.feedback.managed key to store feedback information to be queried over MDM, as described in the Apple Mobile Device Management Protocol Reference documentation. """ ] case .CORELOCATION_FRAMEWORK_KEY: return [:] // TODO case .HEALTHKIT_FRAMEWORK_KEY: return [:] // TODO case .CRASH_FRAMEWORK_KEY: return [:] // TODO case .CONTACTS_FRAMEWORK_KEY: return [:] // TODO case .THIRD_PARTY_SDK_KEY: return [ "reason" : """ \(CliSyntaxColor.BLUE_BRIGHT) ℹ---------------------------------------------------------------------------------+ | You must include the privacy manifest for any of the above SDKs when you submit | | new apps in App Store Connect that include those SDKs, or when you submit an | | app update that adds one of the listed SDKs as part of the update. | | Signatures are also required in these cases where the listed SDKs are used | | as binary dependencies. Any version of a listed SDK, as well as any SDKs that | | repackage those on the list, are included in the requirement. | +---------------------------------------------------------------------------------+ \(CliSyntaxColor.END) """] } } var link: String { switch self { case .FILE_TIMESTAMP_APIS_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278393" case .SYSTEM_BOOT_APIS_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278394" case .DISK_SPACE_APIS_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278397" case .ACTIVE_KEYBOARD_APIS_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278400" case .USER_DEFAULTS_APIS_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278401" case .CORELOCATION_FRAMEWORK_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests#4263133" case .HEALTHKIT_FRAMEWORK_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests#4263132" case .CRASH_FRAMEWORK_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests#4263159" case .CONTACTS_FRAMEWORK_KEY: return "https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests#4263130" case .THIRD_PARTY_SDK_KEY: return "https://developer.apple.com/support/third-party-SDK-requirements/" } } } let ALLOWED_EXTENSIONS = [ "m", // Objective-C "mm", // Objective-C++ "c", // C "cpp", // C++ "swift" // Swift ] // Look through the code for the listed strings (Case Sensitive) let APIS_TO_CHECK: [String: [RequiredReasonKey]] = [ // Ref: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api "creationDate": [.FILE_TIMESTAMP_APIS_KEY], "modificationDate": [.FILE_TIMESTAMP_APIS_KEY], "fileModificationDate": [.FILE_TIMESTAMP_APIS_KEY], "contentModificationDateKey": [.FILE_TIMESTAMP_APIS_KEY], "creationDateKey": [.FILE_TIMESTAMP_APIS_KEY], "getattrlist(": [.FILE_TIMESTAMP_APIS_KEY, .DISK_SPACE_APIS_KEY], // also covers: fgetattrlist( "getattrlistbulk(": [.FILE_TIMESTAMP_APIS_KEY], "fstat(": [.FILE_TIMESTAMP_APIS_KEY], "fstatat(": [.FILE_TIMESTAMP_APIS_KEY], "lstat(": [.FILE_TIMESTAMP_APIS_KEY], "getattrlistat(": [.FILE_TIMESTAMP_APIS_KEY, .DISK_SPACE_APIS_KEY], "systemUptime": [.SYSTEM_BOOT_APIS_KEY], "mach_absolute_time(": [.SYSTEM_BOOT_APIS_KEY], "volumeAvailableCapacityKey": [.DISK_SPACE_APIS_KEY], "volumeAvailableCapacityForImportantUsageKey": [.DISK_SPACE_APIS_KEY], "volumeAvailableCapacityForOpportunisticUsageKey": [.DISK_SPACE_APIS_KEY], "volumeTotalCapacityKey": [.DISK_SPACE_APIS_KEY], "systemFreeSize": [.DISK_SPACE_APIS_KEY], "systemSize": [.DISK_SPACE_APIS_KEY], "statfs(": [.DISK_SPACE_APIS_KEY], // also covers: fstatfs( "statvfs(": [.DISK_SPACE_APIS_KEY], // also covers: fstatvfs( "activeInputModes": [.ACTIVE_KEYBOARD_APIS_KEY], "UserDefaults": [.USER_DEFAULTS_APIS_KEY], // Ref: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests "import CoreLocation": [.CORELOCATION_FRAMEWORK_KEY], "#import ": [.CORELOCATION_FRAMEWORK_KEY], "import HealthKit": [.HEALTHKIT_FRAMEWORK_KEY], "#import ": [.HEALTHKIT_FRAMEWORK_KEY], "#import ": [.HEALTHKIT_FRAMEWORK_KEY], "import Sentry": [.CRASH_FRAMEWORK_KEY], "#import ": [.CRASH_FRAMEWORK_KEY], "import Instabug": [.CRASH_FRAMEWORK_KEY], "#import ": [.CRASH_FRAMEWORK_KEY], "#import \"Countly.h\"": [.CRASH_FRAMEWORK_KEY], "import Bugsnag": [.CRASH_FRAMEWORK_KEY], "#import ": [.CRASH_FRAMEWORK_KEY], "#import ": [.CONTACTS_FRAMEWORK_KEY], "#import ": [.CONTACTS_FRAMEWORK_KEY] ] // Look through the Frameworks Build Phase or Package Dependencies for the // listed strings (Case Insensitive) let SDKS_TO_CHECK: [String: RequiredReasonKey] = [ // Ref: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests "sentry-cocoa": .CRASH_FRAMEWORK_KEY, "Sentry": .CRASH_FRAMEWORK_KEY, "Instabug": .CRASH_FRAMEWORK_KEY, "countly": .CRASH_FRAMEWORK_KEY, "Bugsnag": .CRASH_FRAMEWORK_KEY, "firebase": .CRASH_FRAMEWORK_KEY, // TODO: Add more third-party crash frameworks here "CoreLocation": .CORELOCATION_FRAMEWORK_KEY, "HealthKit": .HEALTHKIT_FRAMEWORK_KEY, "Contacts": .CONTACTS_FRAMEWORK_KEY, // also covers ContactsUI // Ref: https://developer.apple.com/support/third-party-SDK-requirements/ "Abseil": .THIRD_PARTY_SDK_KEY, "AFNetworking": .THIRD_PARTY_SDK_KEY, "Alamofire": .THIRD_PARTY_SDK_KEY, "AppAuth": .THIRD_PARTY_SDK_KEY, // also covers: GTMAppAuth "BoringSSL": .THIRD_PARTY_SDK_KEY, "openssl_grpc": .THIRD_PARTY_SDK_KEY, "Capacitor": .THIRD_PARTY_SDK_KEY, "Charts": .THIRD_PARTY_SDK_KEY, "connectivity_plus": .THIRD_PARTY_SDK_KEY, "Cordova": .THIRD_PARTY_SDK_KEY, "device_info_plus": .THIRD_PARTY_SDK_KEY, "DKImagePickerController": .THIRD_PARTY_SDK_KEY, "DKPhotoGallery": .THIRD_PARTY_SDK_KEY, "FBAEMKit": .THIRD_PARTY_SDK_KEY, "FBLPromises": .THIRD_PARTY_SDK_KEY, "FBSDKCoreKit": .THIRD_PARTY_SDK_KEY, // also covers: FBSDKCoreKit_Basics "FBSDKLoginKit": .THIRD_PARTY_SDK_KEY, "FBSDKShareKit": .THIRD_PARTY_SDK_KEY, "file_picker": .THIRD_PARTY_SDK_KEY, "FirebaseABTesting": .THIRD_PARTY_SDK_KEY, "FirebaseAuth": .THIRD_PARTY_SDK_KEY, "FirebaseCore": .THIRD_PARTY_SDK_KEY, // also covers: FirebaseCoreDiagnostics, FirebaseCoreExtension, FirebaseCoreInternal "FirebaseCrashlytics": .THIRD_PARTY_SDK_KEY, "FirebaseDynamicLinks": .THIRD_PARTY_SDK_KEY, "FirebaseFirestore": .THIRD_PARTY_SDK_KEY, "FirebaseInstallations": .THIRD_PARTY_SDK_KEY, "FirebaseMessaging": .THIRD_PARTY_SDK_KEY, "FirebaseRemoteConfig": .THIRD_PARTY_SDK_KEY, "Flutter": .THIRD_PARTY_SDK_KEY, // also covers: flutter_inappwebview, flutter_local_notifications, fluttertoast "FMDB": .THIRD_PARTY_SDK_KEY, "geolocator_apple": .THIRD_PARTY_SDK_KEY, "GoogleDataTransport": .THIRD_PARTY_SDK_KEY, "GoogleSignIn": .THIRD_PARTY_SDK_KEY, "GoogleToolboxForMac": .THIRD_PARTY_SDK_KEY, "GoogleUtilities": .THIRD_PARTY_SDK_KEY, "grpcpp": .THIRD_PARTY_SDK_KEY, "GTMSessionFetcher": .THIRD_PARTY_SDK_KEY, "hermes": .THIRD_PARTY_SDK_KEY, "image_picker_ios": .THIRD_PARTY_SDK_KEY, "IQKeyboardManager": .THIRD_PARTY_SDK_KEY, // also covers: IQKeyboardManagerSwift "Kingfisher": .THIRD_PARTY_SDK_KEY, "leveldb": .THIRD_PARTY_SDK_KEY, "Lottie": .THIRD_PARTY_SDK_KEY, "MBProgressHUD": .THIRD_PARTY_SDK_KEY, "nanopb": .THIRD_PARTY_SDK_KEY, "OneSignal": .THIRD_PARTY_SDK_KEY, // also covers: OneSignalCore, OneSignalExtension, OneSignalOutcomes "OpenSSL": .THIRD_PARTY_SDK_KEY, "OrderedSet": .THIRD_PARTY_SDK_KEY, "package_info": .THIRD_PARTY_SDK_KEY, // also covers: package_info_plus "path_provider": .THIRD_PARTY_SDK_KEY, // also covers: path_provider_ios "Promises": .THIRD_PARTY_SDK_KEY, "Protobuf": .THIRD_PARTY_SDK_KEY, "Reachability": .THIRD_PARTY_SDK_KEY, "RealmSwift": .THIRD_PARTY_SDK_KEY, "RxCocoa": .THIRD_PARTY_SDK_KEY, "RxRelay": .THIRD_PARTY_SDK_KEY, "RxSwift": .THIRD_PARTY_SDK_KEY, "SDWebImage": .THIRD_PARTY_SDK_KEY, "share_plus": .THIRD_PARTY_SDK_KEY, "shared_preferences_ios": .THIRD_PARTY_SDK_KEY, "SnapKit": .THIRD_PARTY_SDK_KEY, "sqflite": .THIRD_PARTY_SDK_KEY, "Starscream": .THIRD_PARTY_SDK_KEY, "SVProgressHUD": .THIRD_PARTY_SDK_KEY, "SwiftyGif": .THIRD_PARTY_SDK_KEY, "SwiftyJSON": .THIRD_PARTY_SDK_KEY, "Toast": .THIRD_PARTY_SDK_KEY, "UnityFramework": .THIRD_PARTY_SDK_KEY, "url_launcher": .THIRD_PARTY_SDK_KEY, // also covers: url_launcher_ios "video_player_avfoundation": .THIRD_PARTY_SDK_KEY, "wakelock": .THIRD_PARTY_SDK_KEY, "webview_flutter_wkwebview": .THIRD_PARTY_SDK_KEY ] ================================================ FILE: Sources/PrivacyManifest/DirectoryProjectParser.swift ================================================ // // DirectoryProjectParser.swift // // // Created by Stelios Petrakis on 15/4/24. // import Foundation import PathKit // Recursively rarses all the children files of the provided path, detects the // supported files and parses them. class DirectoryProjectParser: ProjectParser { override func parse() throws { print("---") let targetName = projectPath.lastComponent let spinner = concurrentStream.createSilentSpinner(with: "Parsing \(CliSyntaxColor.GREEN)\(targetName)'s\(CliSyntaxColor.END) source files...") concurrentStream.start(spinner: spinner) do { var filePathsForParsing: [Path] = [] try self.projectPath.recursiveChildren().forEach { path in guard let ext = path.extension, ALLOWED_EXTENSIONS.contains(ext) else { return } filePathsForParsing.append(path) } try self.parseFiles(filePathsForParsing: filePathsForParsing, targetName: targetName, spinner: spinner) self.concurrentStream.success(spinner: spinner, "Parsed \(CliSyntaxColor.GREEN)\(targetName)'s\(CliSyntaxColor.END) source files") } catch { self.concurrentStream.error(spinner: spinner, "Error parsing \(CliSyntaxColor.GREEN)\(targetName)'s\(CliSyntaxColor.END) source files: \(CliSyntaxColor.RED)\(error)\(CliSyntaxColor.END)") } _ = group.wait(timeout: .distantFuture) concurrentStream.waitAndShowCursor() } } ================================================ FILE: Sources/PrivacyManifest/ProjectParser.swift ================================================ // // ProjectParser.swift // // // Created by Stelios Petrakis on 14/4/24. // import Foundation import Spinner import PathKit struct ParsedResult: Hashable { var line: String var lineNumber: Int? var range: Range } struct PresentedResult: Hashable { var filePath: String var formattedLine: String? var parsedResult: ParsedResult? } class ProjectParser { private var requiredAPIs: [RequiredReasonKey: Set] = [:] private let requiredAPIsLock = NSLock() let concurrentStream = ConcurrentSpinnerStream() let group = DispatchGroup() let queue = DispatchQueue(label: "parser", attributes: .concurrent) let projectPath: Path init(with projectPath: Path) { self.projectPath = projectPath RequiredReasonKey.allCases.forEach { key in requiredAPIs[key] = Set() } } func parse() throws { } final func parseFiles(filePathsForParsing: [Path], targetName: String, spinner: Spinner) throws { guard filePathsForParsing.count > 0 else { return } var parsed = 1 for filePath in filePathsForParsing { self.queue.async(group: group, execute: DispatchWorkItem(block: { defer { parsed += 1 self.concurrentStream.message(spinner: spinner, "Parsing \(CliSyntaxColor.GREEN)\(targetName)'s\(CliSyntaxColor.END) source files (\(parsed)/\(filePathsForParsing.count))...") } guard let fileHandle = FileHandle(forReadingAtPath: filePath.string) else { return } do { guard let data = try fileHandle.readToEnd(), let contents = String(data: data, encoding: .utf8) else { return } Self.lookForAPI(contents: contents).forEach { (key, parsedResult) in let highlightedCode = "\(Self.addBracketsToString(parsedResult.line,around: parsedResult.range))" var formattedLine = "" if let lineNumber = parsedResult.lineNumber { formattedLine = "\(CliSyntaxColor.GREEN)\(lineNumber):\(CliSyntaxColor.END)\t\(highlightedCode)" } else { formattedLine = "\(highlightedCode)" } self.updateRequiredAPIs(key, with: PresentedResult(filePath: filePath.string, formattedLine: formattedLine, parsedResult: parsedResult)) } } catch { self.concurrentStream.error(spinner: spinner, "Error: \(error)") } })) } } final func updateRequiredAPIs(_ key: RequiredReasonKey, with presentedResult: PresentedResult) { self.requiredAPIsLock.lock() self.requiredAPIs[key]?.update(with: presentedResult) self.requiredAPIsLock.unlock() } final func process(revealOccurrences: Bool) -> [RequiredReasonKey: Set] { print("---") requiredAPIs.sorted(by: { if $0.value.count == $1.value.count { $0.key.hashValue < $1.key.hashValue } else { $0.value.count < $1.value.count } }).forEach { (key, list) in print("\(CliSyntaxColor.WHITE_BOLD)\(key.description) (\(list.count) \(list.count == 1 ? "occurrence" : "occurrences")\(CliSyntaxColor.END))") if !revealOccurrences { return } var currentPath = "" list.sorted(by: { if $0.parsedResult == nil && $1.parsedResult != nil { return true } else if $0.parsedResult != nil && $1.parsedResult == nil { return false } else if let firstParsedResult = $0.parsedResult, let secondParentResult = $1.parsedResult { if $0.filePath == $1.filePath { return firstParsedResult.lineNumber ?? -1 < secondParentResult.lineNumber ?? -1 } else { return $0.filePath < $1.filePath } } return $0.filePath < $1.filePath }).forEach { presentedResult in if presentedResult.filePath != currentPath { print("\n\t\(presentedResult.formattedLine != nil ? "✎" : "⛺︎") \(presentedResult.filePath)\(presentedResult.formattedLine != nil ? ":" : "")") } if let formattedLine = presentedResult.formattedLine { print("\t\t\(formattedLine)") } currentPath = presentedResult.filePath } print("\n") } return requiredAPIs } } // Helper methods extension ProjectParser { static func lookForAPI(contents: String) -> [(RequiredReasonKey, ParsedResult)] { var foundAPIs: [(RequiredReasonKey, ParsedResult)] = [] var lineNumber = 1 contents.components(separatedBy: .newlines).forEach { line in APIS_TO_CHECK.forEach { (api, requiredReasonKeys) in let results = mark(searchString: api, in: line, lineNumber: lineNumber, requiredReasonKeys: requiredReasonKeys) foundAPIs.append(contentsOf: results) } lineNumber += 1 } return foundAPIs } static func mark(searchString: String, in line: String, lineNumber: Int?, caseInsensitive: Bool = false, requiredReasonKeys: [RequiredReasonKey]) -> [(RequiredReasonKey, ParsedResult)] { var parsedResults: [(RequiredReasonKey, ParsedResult)] = [] var searchRange = line.startIndex.., openingTag: String, closingTag: String) -> String { let lowerBoundIndex = range.lowerBound let upperBoundIndex = range.upperBound var modifiedString = string modifiedString.replaceSubrange(lowerBoundIndex..) -> String { return addTagsToString(string, around: range, openingTag: CliSyntaxColor.YELLOW, closingTag: CliSyntaxColor.END) } } ================================================ FILE: Sources/PrivacyManifest/SpinnerStreams.swift ================================================ // // SpinnerStreams.swift // // // Created by Stelios Petrakis on 14/4/24. // import Foundation import Spinner // Displays several different spinner outputs at once class ConcurrentSpinnerStream { // The array of concurrent silent spinner streams to manage var silentSpinnerStreams: [SilentSpinnerStream] = [] private static let MAX_OUTPUT_LINES = 10 private var previousRows = 0 // Serial queue that ensures that console output is serial. private let queue = DispatchQueue(label: "stream.queue") private let group = DispatchGroup() // Serial queue that ensures that there are no race conditions on spinner // calls. private let spinnersQueue = DispatchQueue(label: "spinner.queue") private let spinnersGroup = DispatchGroup() init() { Self.hideCursor() } // Renders the added silent spinner streams // NOTE: Only call it from within the serial qeueue private func render() { guard silentSpinnerStreams.count > 0 else { return } // Move cursor at the beginning of the previously rendered string if previousRows > 0 { print("\u{001B}[\(previousRows)F", terminator: "") } // Clear from cursor to end of screen print("\u{001B}[0J", terminator: "") // Generate the buffer var buffer = "" var linesRendered = 0 silentSpinnerStreams.sorted().forEach { silentSpinner in if linesRendered > Self.MAX_OUTPUT_LINES { return } buffer.append(silentSpinner.buffer + "\n") linesRendered += 1 } print("\(buffer)", terminator: "") fflush(stdout) previousRows = linesRendered } // Hides the cursor from console static func hideCursor() { print("\u{001B}[?25l", terminator: "") fflush(stdout) } // Shows the cursor to console static func showCursor() { print("\u{001B}[?25h", terminator: "") fflush(stdout) } func waitAndShowCursor() { // Wait until all async requests have been printed _ = spinnersGroup.wait(timeout: .distantFuture) _ = group.wait(timeout: .distantFuture) Self.showCursor() silentSpinnerStreams.removeAll() previousRows = 0 } // Adds a silent spinner stream func add(stream: SilentSpinnerStream) { queue.async(group: group, execute: DispatchWorkItem(block: { self.silentSpinnerStreams.append(stream) })) } /// Execute an asynchronous task on the serial queue and optionally render the added silent spinner /// streams. /// /// - Parameters: /// - work: The task to be completed asynchronously within the serial queue private func executeSpinnerAsync(work: @escaping () -> Void) { spinnersQueue.async(group: spinnersGroup, execute: DispatchWorkItem(block: { work() })) } fileprivate func executeSpinnerStreamAsync(work: @escaping () -> Void) { queue.async(group: group, execute: DispatchWorkItem(block: { work() self.render() })) } func start(spinner: Spinner) { executeSpinnerAsync { spinner.start() } } func success(spinner: Spinner, _ message: String) { executeSpinnerAsync { spinner.success(message) } } func message(spinner: Spinner, _ message: String) { executeSpinnerAsync { spinner.message(message) } } func error(spinner: Spinner, _ message: String) { executeSpinnerAsync { spinner.error(message) } } func createSilentSpinner(with message: String) -> Spinner { let silentSpinnerStream = SilentSpinnerStream(concurrentStream: self) return Spinner(.dots8Bit, message, stream: silentSpinnerStream) } } // Writes the spinner stream to a buffer, instead of the stdout class SilentSpinnerStream: SpinnerStream, Comparable { static func == (lhs: SilentSpinnerStream, rhs: SilentSpinnerStream) -> Bool { lhs.lastUpdated == rhs.lastUpdated } static func < (lhs: SilentSpinnerStream, rhs: SilentSpinnerStream) -> Bool { lhs.lastUpdated > rhs.lastUpdated } var buffer = "" var lastUpdated: TimeInterval private var concurrentStream: ConcurrentSpinnerStream init(concurrentStream: ConcurrentSpinnerStream) { self.lastUpdated = Date().timeIntervalSince1970 self.concurrentStream = concurrentStream concurrentStream.add(stream: self) } func write(string: String, terminator: String) { guard string.count > 0 else { return } concurrentStream.executeSpinnerStreamAsync(work: { self.lastUpdated = Date().timeIntervalSince1970 // If the message contains a success or an error character, treat // it as the final message for that spinner stream. guard !self.buffer.contains("✔") && !self.buffer.contains("✖") else { return } self.buffer = string }) } func hideCursor() { } func showCursor() { } } ================================================ FILE: Sources/PrivacyManifest/SwiftPackageProjectParser.swift ================================================ // // SwiftPackageProjectParser.swift // // // Created by Stelios Petrakis on 14/4/24. // import Foundation import Spinner import PathKit import PackageModel import PackageLoading import PackageGraph import Workspace import Basics import class TSCBasic.Process import func TSCBasic.tsc_await extension SwiftSDK { package static var `default`: Self { get throws { // ref: https://github.com/compnerd/swift-package-manager/blob/master/Examples/package-info/Sources/package-info/main.swift#L10 let swiftCompiler: AbsolutePath? = { let string: String #if os(macOS) string = try! Process.checkNonZeroExit(args: "xcrun", "--sdk", "macosx", "-f", "swiftc").spm_chomp() #else string = try! Process.checkNonZeroExit(args: "which", "swiftc").spm_chomp() #endif return try! AbsolutePath(validating: string) }() return try! SwiftSDK.hostSwiftSDK(swiftCompiler) } } } extension UserToolchain { package static var `default`: Self { get throws { return try .init(swiftSDK: SwiftSDK.default) } } } // Parses all the Swift Package's supported source files and dependencies. class SwiftPackageProjectParser : ProjectParser { override func parse() throws { print("---") let spinner = Spinner(.dots8Bit, "Resolving graph...") spinner.start() let manifestLoader = try ManifestLoader(toolchain: UserToolchain.default) let packageAbsolutePath = try AbsolutePath(validating: projectPath.string) let root = packageAbsolutePath.parentDirectory // ref: https://github.com/unsignedapps/swift-create-xcframework/blob/0be3a68c84987493a7d7298027274a0862bc5ccd/Sources/CreateXCFramework/PackageInfo.swift#L93 let workspace = try Workspace( forRootPackage: root, customManifestLoader: manifestLoader ) // Only print warning and error messages let observability = Basics.ObservabilitySystem { _, diagnostics in guard diagnostics.severity != .debug && diagnostics.severity != .info else { return } print("\(diagnostics.severity): \(diagnostics.message)") } let scope = observability.topScope let graph = try workspace.loadPackageGraph( rootPath: root, observabilityScope: scope ) spinner.success("Resolved graph") graph.requiredDependencies.forEach { dependency in guard !dependency.kind.isRoot else { return } let dependencyString = dependency.canonicalLocation.description let spinner = concurrentStream.createSilentSpinner(with: "Parsing \(CliSyntaxColor.GREEN)\(dependencyString)\(CliSyntaxColor.END) dependency...") concurrentStream.start(spinner: spinner) queue.async(group: group, execute: DispatchWorkItem(block: { SDKS_TO_CHECK.forEach { (key, value) in let markedResults = Self.mark(searchString: key, in: dependencyString, lineNumber: nil, caseInsensitive: true, requiredReasonKeys: [value]) guard let firstResult = markedResults.first?.1 else { return } let highlightedCode = "\(Self.addBracketsToString(firstResult.line,around: firstResult.range))" let foundInBuildPhase = "Found \(highlightedCode) in dependencies." self.updateRequiredAPIs(value, with: PresentedResult(filePath: foundInBuildPhase)) } self.concurrentStream.success(spinner: spinner, "Parsed \(CliSyntaxColor.GREEN)\(dependencyString)\(CliSyntaxColor.END) dependency") })) } // We only care about the targets of the root packages, not the // dependencies graph.rootPackages.forEach { package in package.targets.forEach { target in // Exclude test targets guard target.type != .test else { return } let spinner = concurrentStream.createSilentSpinner(with: "Parsing \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) source files...") concurrentStream.start(spinner: spinner) var filePathsForParsing: [Path] = [] let rootDirectory = target.sources.root target.sources.relativePaths.forEach { relativePath in guard let ext = relativePath.extension, ALLOWED_EXTENSIONS.contains(ext) else { return } filePathsForParsing.append(Path(rootDirectory.pathString) + relativePath.pathString) } do { try self.parseFiles(filePathsForParsing: filePathsForParsing, targetName: target.name, spinner: spinner) self.concurrentStream.success(spinner: spinner, "Parsed \(filePathsForParsing.count) \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) source files") } catch { self.concurrentStream.error(spinner: spinner, "Error parsing \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) source files: \(CliSyntaxColor.RED)\(error)\(CliSyntaxColor.END)") } } } _ = group.wait(timeout: .distantFuture) concurrentStream.waitAndShowCursor() print("---") } } ================================================ FILE: Sources/PrivacyManifest/XcodeProjectParser.swift ================================================ // // XcodeProjectParser.swift // // // Created by Stelios Petrakis on 14/4/24. // import Foundation import Spinner import PathKit import XcodeProj // Parses all Xcode projects contained in a Xcode workspace class XcodeWorkspaceParser: XcodeProjectParser { override func parse() throws { print("---") let xcworkspace = try XCWorkspace(path: projectPath) try xcworkspace.data.children.filter { Path($0.location.path).extension == XCODE_PROJECT_PATH_EXTENSION }.forEach { element in print("\(CliSyntaxColor.WHITE_BOLD)\(element.location.path)\(CliSyntaxColor.END)") ConcurrentSpinnerStream.hideCursor() let projectPath = projectPath.parent() + Path(element.location.path) try parseProject(projectPath) } } } // Parses all targets' supported source files and frameworks. class XcodeProjectParser: ProjectParser { var deepLibraryFrameworkCheck = false init(with path: Path, deepLibraryFrameworkCheck: Bool = false) { super.init(with: path) self.deepLibraryFrameworkCheck = deepLibraryFrameworkCheck } override func parse() throws { print("---") try parseProject(projectPath) } fileprivate func parseProject(_ path: Path) throws { let xcodeproj = try XcodeProj(path: path) // Gather all local and remote package dependencies from the root // project. var packageDepedencyNames: [String] = [] do { try xcodeproj.pbxproj.rootProject()?.localPackages.compactMap { $0.name }.forEach { packageDepedencyNames.append($0) } try xcodeproj.pbxproj.rootProject()?.remotePackages.compactMap { $0.name }.forEach { packageDepedencyNames.append($0) } } catch { // Suppress any errors due to missing references. } packageDepedencyNames.forEach { packageDepedencyName in let spinner = concurrentStream.createSilentSpinner(with: "Looking up \(CliSyntaxColor.GREEN)\(packageDepedencyName)'s\(CliSyntaxColor.END) package dependency...") concurrentStream.start(spinner: spinner) queue.async(group: group, execute: DispatchWorkItem(block: { SDKS_TO_CHECK.forEach { (key, value) in let markedResults = Self.mark(searchString: key, in: packageDepedencyName, lineNumber: nil, caseInsensitive: true, requiredReasonKeys: [value]) guard let firstResult = markedResults.first?.1 else { return } let highlightedCode = "\(Self.addBracketsToString(firstResult.line,around: firstResult.range))" let foundInBuildPhase = "Found \(highlightedCode) in Package Dependencies." self.updateRequiredAPIs(value, with: PresentedResult(filePath: foundInBuildPhase)) } self.concurrentStream.success(spinner: spinner, "Parsed \(CliSyntaxColor.GREEN)\(packageDepedencyName)\(CliSyntaxColor.END) package dependency") })) } try xcodeproj.pbxproj.nativeTargets.forEach { target in guard let productType = target.productType else { return } // Skip UI / Unit tests if productType == .unitTestBundle || productType == .uiTestBundle { return } if productType == .staticLibrary || productType == .staticFramework || productType == .framework || productType == .xcFramework { let spinner = concurrentStream.createSilentSpinner(with: "Looking up \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) name...") concurrentStream.start(spinner: spinner) queue.async(group: group, execute: DispatchWorkItem(block: { SDKS_TO_CHECK.forEach { (key, value) in let markedResults = Self.mark(searchString: key, in: target.name, lineNumber: nil, caseInsensitive: true, requiredReasonKeys: [value]) guard let firstResult = markedResults.first?.1 else { return } let highlightedCode = "\(Self.addBracketsToString(firstResult.line,around: firstResult.range))" let foundInBuildPhase = "Found \(highlightedCode)." self.updateRequiredAPIs(value, with: PresentedResult(filePath: foundInBuildPhase)) } self.concurrentStream.success(spinner: spinner, "Looked up \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) name") })) // Do not proceed into looking at the build phase or the source // files of the project targets that are libraries or frameworks // if not instructed. if !deepLibraryFrameworkCheck { return } } // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests target.buildPhases.forEach { phase in guard phase.buildPhase == .frameworks else { return } guard let files = phase.files, files.count > 0 else { return } let spinner = concurrentStream.createSilentSpinner(with: "Parsing \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) Frameworks Build Phase...") concurrentStream.start(spinner: spinner) queue.async(group: group, execute: DispatchWorkItem(block: { files.forEach({ file in guard let fullFileName = file.file?.name else { return } SDKS_TO_CHECK.forEach { (key, value) in let markedResults = Self.mark(searchString: key, in: fullFileName, lineNumber: nil, caseInsensitive: true, requiredReasonKeys: [value]) guard let firstResult = markedResults.first?.1 else { return } let highlightedCode = "\(Self.addBracketsToString(firstResult.line,around: firstResult.range))" let foundInBuildPhase = "Found \(highlightedCode) in \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) Frameworks Build Phase." self.updateRequiredAPIs(value, with: PresentedResult(filePath: foundInBuildPhase)) } }) self.concurrentStream.success(spinner: spinner, "Parsed \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) Frameworks Build Phase") })) } var sourceFiles: [PBXFileElement] = [] do { sourceFiles = try target.sourceFiles() } catch is PBXObjectError { // Suppress PBXObjectError } let spinner = concurrentStream.createSilentSpinner(with: "Parsing \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) source files...") concurrentStream.start(spinner: spinner) do { var filePathsForParsing: [Path] = [] try sourceFiles.forEach { file in guard let filePath = file.path, let ext = Path(filePath).extension, ALLOWED_EXTENSIONS.contains(ext) else { return } guard let fullPath = try file.fullPath(sourceRoot: path.parent()) else { return } filePathsForParsing.append(fullPath) } try self.parseFiles(filePathsForParsing: filePathsForParsing, targetName: target.name, spinner: spinner) self.concurrentStream.success(spinner: spinner, "Parsed \(filePathsForParsing.count) \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) source files") } catch { self.concurrentStream.error(spinner: spinner, "Error parsing \(CliSyntaxColor.GREEN)\(target.name)'s\(CliSyntaxColor.END) source files: \(CliSyntaxColor.RED)\(error)\(CliSyntaxColor.END)") } } _ = group.wait(timeout: .distantFuture) concurrentStream.waitAndShowCursor() print("---") } } ================================================ FILE: Sources/PrivacyManifest/main.swift ================================================ // // main.swift // // // Created by Stelios Petrakis on 9/4/24. // import Foundation import ArgumentParser import Spinner import PathKit struct PrivacyManifest: ParsableCommand { static let configuration = CommandConfiguration( commandName: "privacy-manifest", abstract: "Privacy Manifest tool", discussion: """ An easy and fast way to parse your whole Xcode project, Xcode workspace or Swift Package in order to find whether your codebase makes use of Apple's required reason APIs (https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api) or privacy collected data (https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests). !!! Disclaimer: This tool must *not* be used as the only way to generate the privacy manifest. Do your own research !!! """, version: "0.0.18", subcommands: [Analyze.self]) } struct Analyze: ParsableCommand { private static let PRIVACYINFO_FILENAME = "PrivacyInfo.xcprivacy" // The data structure of the generated PrivacyInfo.xcprivacy file struct PrivacyManifestDataStructure: Encodable { struct PrivacyAccessedAPIType: Encodable { var nsPrivacyAccessedAPIType: String var nSPrivacyAccessedAPITypeReasons: [String] enum CodingKeys: String, CodingKey { case nsPrivacyAccessedAPIType = "NSPrivacyAccessedAPIType" case nSPrivacyAccessedAPITypeReasons = "NSPrivacyAccessedAPITypeReasons" } } var nsPrivacyTracking: Bool var nsPrivacyTrackingDomains: [String] var nsPrivacyCollectedDataTypes: [[String:String]] var nsPrivacyAccessedAPITypes: [PrivacyAccessedAPIType] enum CodingKeys: String, CodingKey { case nsPrivacyTracking = "NSPrivacyTracking" case nsPrivacyTrackingDomains = "NSPrivacyTrackingDomains" case nsPrivacyCollectedDataTypes = "NSPrivacyCollectedDataTypes" case nsPrivacyAccessedAPITypes = "NSPrivacyAccessedAPITypes" } } enum DetectedProjectType { case xcodeProject(Path) case xcodeWorkspace(Path) case swiftPackage(Path) case directory(Path) } public static let configuration = CommandConfiguration( commandName: "analyze", abstract: "Analyzes the project to detect privacy aware API usage", discussion: """ Supports Xcode projects (.xcodeproj), Xcode workspaces (.xcworkspace) and Swift Packages (Package.swift). !!! Disclaimer: This tool must *not* be used as the only way to generate the privacy manifest. Do your own research !!! """ ) @Option(name: .long, help: """ Either the (relative/absolute) path to the project's .xcodeproj(e.g. path/to/MyProject.xcodeproj), .xcworkspace (e.g. path/to/MyWorkspace.xcworkspace) or Package.swift (e.g. path/to/Package.swift). """) private var project : String @Flag(name: .long, help: "Reveals the API occurrences on each file.") var revealOccurrences: Bool = false @Flag(name: .long, help: """ Look up the source files of library / framework targets. By default, when a library / framework target is encountered, the source files are not checked. Warning: If specified, the process might take a while to complete based on the amount of project / targets. """) var deepLibraryFrameworkCheck: Bool = false @Option(name: .long, help: """ The path to the directory where the privacy manifest file will be generated (Optional). """) var output: String? func run() throws { let projectPath = Path(project).absolute() var detectedProjectType: DetectedProjectType? if projectPath.lastComponent == PACKAGE_SWIFT_FILENAME { detectedProjectType = .swiftPackage(projectPath) } else if projectPath.extension == XCODE_PROJECT_PATH_EXTENSION { detectedProjectType = .xcodeProject(projectPath) } else if projectPath.extension == XCODE_WORKSPACE_PATH_EXTENSION { detectedProjectType = .xcodeWorkspace(projectPath) } else if projectPath.isDirectory { // Reverse sort the children paths so that xcworkspace is parsed // first if both .xcodeproj and .xcworkspace are found in the same // path. let children = try projectPath.children().sorted().reversed() guard children.count > 0 else { print("\(CliSyntaxColor.RED)Empty directory: \(projectPath)\(CliSyntaxColor.END)") return } children.forEach { childPath in if detectedProjectType != nil { return } else if childPath.extension == XCODE_WORKSPACE_PATH_EXTENSION { detectedProjectType = .xcodeWorkspace(childPath) } else if childPath.extension == XCODE_PROJECT_PATH_EXTENSION { detectedProjectType = .xcodeProject(childPath) } else if childPath.lastComponent == PACKAGE_SWIFT_FILENAME { detectedProjectType = .swiftPackage(childPath) } } if detectedProjectType == nil { detectedProjectType = .directory(projectPath) } } guard let detectedProjectType = detectedProjectType else { print("\(CliSyntaxColor.RED)File type not supported: \(projectPath)\(CliSyntaxColor.END)") return } var requiredAPIs: [RequiredReasonKey: Set]? do { switch detectedProjectType { case .swiftPackage(let path): print("Swift Package detected: \(CliSyntaxColor.WHITE_BOLD)\(path)\(CliSyntaxColor.END)") let swiftPackage = SwiftPackageProjectParser(with: path) try measure { try swiftPackage.parse() } requiredAPIs = swiftPackage.process(revealOccurrences: revealOccurrences) case .xcodeProject(let path): print("Xcode project detected: \(CliSyntaxColor.WHITE_BOLD)\(path)\(CliSyntaxColor.END)") let xcodeProject = XcodeProjectParser(with: path, deepLibraryFrameworkCheck: deepLibraryFrameworkCheck) try measure { try xcodeProject.parse() } requiredAPIs = xcodeProject.process(revealOccurrences: revealOccurrences) case .xcodeWorkspace(let path): print("Xcode workspace detected: \(CliSyntaxColor.WHITE_BOLD)\(path)\(CliSyntaxColor.END)") let xcodeWorkspace = XcodeWorkspaceParser(with: path, deepLibraryFrameworkCheck: deepLibraryFrameworkCheck) try measure { try xcodeWorkspace.parse() } requiredAPIs = xcodeWorkspace.process(revealOccurrences: revealOccurrences) case .directory(let path): print("Directory detected: \(CliSyntaxColor.WHITE_BOLD)\(path)\(CliSyntaxColor.END)") let xcodeProject = DirectoryProjectParser(with: path) try measure { try xcodeProject.parse() } requiredAPIs = xcodeProject.process(revealOccurrences: revealOccurrences) } } catch { ConcurrentSpinnerStream.showCursor() print("\n\(CliSyntaxColor.RED)✖ Parser Error: \(error)\(CliSyntaxColor.END)") return } if let output = output, let requiredAPIs = requiredAPIs { print("---") let outputPath = Path(output) guard outputPath.isDirectory else { print("\(CliSyntaxColor.RED)Error: Output path not a directory\(CliSyntaxColor.END)") return } generateManifest(requiredAPIs, outputPath: Path(output) + Self.PRIVACYINFO_FILENAME) } } func measure(function: () throws -> Void) throws { let clock = ContinuousClock() let result = try clock.measure(function) print("Execution took \(result)") } func generateManifest(_ requiredAPIs: [RequiredReasonKey: Set], outputPath: Path) { var manifestReasons: [PrivacyManifestDataStructure.PrivacyAccessedAPIType] = [] // Show the THIRD_PARTY_SDK_KEY first requiredAPIs.sorted { if $0.key == .THIRD_PARTY_SDK_KEY { return true } else if $1.key == .THIRD_PARTY_SDK_KEY { return false } else { return $0.key < $1.key } }.forEach { (key, value) in guard value.count > 0, key.reasons.count > 0 else { return } if key == .THIRD_PARTY_SDK_KEY, let reason = key.reasons.first { var results: Set = Set() value.forEach { result in results.update(with: result.filePath) } print("\n\(CliSyntaxColor.WHITE_BOLD)WARNING:\(CliSyntaxColor.END) The following third-party SDKs were detected:\n") print("* \(results.joined(separator: "\n* "))") print(reason.value) print("\(CliSyntaxColor.CYAN)⚓︎ \(key.link)\(CliSyntaxColor.END)\n") print("ENTER to continue", terminator: "") _ = readLine() return } guard let privacyManifestKey = key.privacyManifestKey else { return } print("\n\(CliSyntaxColor.WHITE_BOLD)\(value.count) \(value.count == 1 ? "occurrence" : "occurrences") for \(key.description)\(CliSyntaxColor.END). Available reasons:\n") var index = 0 let reasonKeys = [String](key.reasons.keys) reasonKeys.forEach { reasonKey in guard let value = key.reasons[reasonKey] else { return } print(""" \(CliSyntaxColor.WHITE_BOLD)\(index+1).\(CliSyntaxColor.END) \(value)\n """) index += 1 } print("\(CliSyntaxColor.CYAN)⚓︎ \(key.link)\(CliSyntaxColor.END)\n") print("Enter values \(1)-\(reasonKeys.count) that match your case (comma separated for multiple values, ENTER for none): ", terminator: "") var manifestReasonKeys: [String] = [] if let input = readLine() { let values = input.components(separatedBy: ",") values.forEach { value in guard let index = Int(value.trimmingCharacters(in: .whitespaces)), index - 1 >= 0 && index - 1 < reasonKeys.count else { return } let reasonKey = reasonKeys[index - 1] manifestReasonKeys.append(reasonKey) } } if manifestReasonKeys.count > 0 { manifestReasons.append(PrivacyManifestDataStructure.PrivacyAccessedAPIType( nsPrivacyAccessedAPIType: privacyManifestKey, nSPrivacyAccessedAPITypeReasons: manifestReasonKeys)) } } print("\n") guard manifestReasons.count > 0 else { print("\(CliSyntaxColor.YELLOW)No reasons were provided, Privacy Manifest file generation was skipped.\(CliSyntaxColor.END)") return } let privacyManifestDataStructure = PrivacyManifestDataStructure( nsPrivacyTracking: false, nsPrivacyTrackingDomains: [], nsPrivacyCollectedDataTypes: [], nsPrivacyAccessedAPITypes: manifestReasons) do { try PropertyListEncoder().encode(privacyManifestDataStructure).write(to: outputPath.url) print("\(CliSyntaxColor.GREEN)✔\(CliSyntaxColor.END) Privacy Manifest file was generated successfully at \(CliSyntaxColor.WHITE_BOLD)\(outputPath.absolute())\(CliSyntaxColor.END)") print(""" \(CliSyntaxColor.YELLOW_BRIGHT) ⚠--------------------------- Disclaimer ---------------------------+ | Check the values of the generated Privacy Manifest file before | | submitting your application to the App Store for review, as only | | the values of the NSPrivacyAccessedAPITypes dictionary are based | | on your responses. | +------------------------------------------------------------------+ \(CliSyntaxColor.END) """) } catch { print("\(CliSyntaxColor.RED)✖ Error generating Privacy Manifest file: \(error)\(CliSyntaxColor.END)") } } } PrivacyManifest.main()