├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ ├── Pouch-Package.xcscheme │ ├── PouchFramework.xcscheme │ └── pouch.xcscheme ├── Changelog.md ├── Code of Conduct.md ├── License.md ├── Makefile ├── Package.resolved ├── Package.swift ├── Readme.md ├── Sources ├── Pouch │ ├── Commands │ │ ├── Pouch.swift │ │ └── Retrieve.swift │ └── main.swift └── PouchFramework │ ├── Chalk.swift │ ├── Defaults.swift │ ├── Engine.swift │ ├── Extensions │ ├── Collection.swift │ ├── Sequence.swift │ ├── String.swift │ ├── UInt8.swift │ └── URL.swift │ ├── Generators │ └── Swift │ │ ├── SwiftCipherContentsGenerating.swift │ │ ├── SwiftGenerator.swift │ │ └── SwiftXorGenerator.swift │ ├── Logger.swift │ ├── Models │ ├── Cipher.swift │ ├── Configuration.swift │ ├── DecryptionFile.swift │ ├── Input.swift │ ├── LanguageOutput.swift │ ├── Languages │ │ └── SwiftConfig.swift │ ├── Output.swift │ ├── Secret.swift │ └── SecretDeclaration.swift │ └── VariableFetchers │ ├── EnvironmentVariablesFetcher.swift │ ├── VariableFetching.swift │ └── VariableFetchingError.swift └── Tests ├── LinuxMain.swift └── PouchTests ├── Generators └── SwiftGeneratorTests.swift ├── Parsers ├── ConfigurationParsingTests.swift ├── OutputParsingTests.swift └── SecretParsingTests.swift ├── PouchTests.swift ├── Utilities.swift └── XCTestManifests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | Secrets.swift 7 | .pouch.yml 8 | pouch.zip -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Pouch-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 61 | 67 | 68 | 69 | 70 | 71 | 81 | 82 | 88 | 89 | 90 | 91 | 97 | 98 | 104 | 105 | 106 | 107 | 109 | 110 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/PouchFramework.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/pouch.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | ## Next 2 | 3 | ## 0.2.0 (2021-04-02) 4 | - Added `swiftlint:disable all` to the top of the file. [@Igor-Palaguta] 5 | - Added "Generated using Pouch" to the top of the file. [@Igor-Palaguta] 6 | - Fixed spacing in the generated file. [@Igor-Palaguta] 7 | 8 | ## 0.1.2 (2021-03-24) 9 | - Resigned from building fat binaries due to problems with brew & intel macs. 10 | 11 | ## 0.1.1 (2021-03-24) 12 | - Small updates to Makefile for brew support. 13 | 14 | ## 0.1.0 (2021-03-24) 15 | - No need for `filePath` keyword in the `.pouch.yml` output if there is a single path (with multiple config options you need to type parameter names, similar to how secret parsing works). 16 | - `pouch` will now by default run `pouch retrieve`. 17 | - Fixed a bug where if you didn't provide output language it would print parser error. 18 | 19 | ## 0.0.1 (2021-03-15) 20 | - Initial release 🥳 21 | 22 | [@Igor-Palaguta]: https://github.com/Igor-Palaguta -------------------------------------------------------------------------------- /Code of Conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | thesunshinejr@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present @sunshinejr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Slightly adjusted from: https://github.com/yonaskolb/Mint/blob/master/Makefile (thanks @yonaskolb!) 2 | EXECUTABLE_NAME = pouch 3 | REPO = https://github.com/sunshinejr/Pouch 4 | VERSION = 0.2.0 5 | 6 | PREFIX = /usr/local 7 | INSTALL_PATH = $(PREFIX)/bin/$(EXECUTABLE_NAME) 8 | BUILD_PATH = .build/release/$(EXECUTABLE_NAME) 9 | CURRENT_PATH = $(PWD) 10 | RELEASE_TAR = $(REPO)/archive/$(VERSION).tar.gz 11 | CURRENT_DATE = $(shell date +%F) 12 | 13 | .PHONY: install build uninstall publish release 14 | 15 | install: build 16 | mkdir -p $(PREFIX)/bin 17 | cp -f $(BUILD_PATH) $(INSTALL_PATH) 18 | 19 | build: 20 | swift build --disable-sandbox -c release 21 | 22 | uninstall: 23 | rm -f $(INSTALL_PATH) 24 | 25 | publish: zip_binary bump_brew 26 | echo "Published $(VERSION)" 27 | 28 | bump_brew: 29 | brew update 30 | brew bump-formula-pr --url=$(RELEASE_TAR) Pouch 31 | 32 | zip_binary: build 33 | zip -jr $(EXECUTABLE_NAME).zip $(BUILD_PATH) 34 | 35 | release: 36 | sed -i '' 's|\(version: "\)\(.*\)\("\)|\1$(VERSION)\3|' Sources/Pouch/Commands/Pouch.swift 37 | sed -i '' 's/## Next/## Next\n\n## $(VERSION) ($(CURRENT_DATE))/g' Changelog.md 38 | 39 | git add . 40 | git commit -m "Releasing $(VERSION)" 41 | git tag $(VERSION) 42 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 7 | "state": { 8 | "branch": null, 9 | "revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc", 10 | "version": "0.3.2" 11 | } 12 | }, 13 | { 14 | "package": "Yams", 15 | "repositoryURL": "https://github.com/jpsim/Yams", 16 | "state": { 17 | "branch": null, 18 | "revision": "9003d51672e516cc59297b7e96bff1dfdedcb4ea", 19 | "version": "4.0.4" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Pouch", 6 | platforms: [.macOS(.v10_11)], 7 | products: [ 8 | .executable(name: "pouch", targets: ["Pouch"]), 9 | .library(name: "PouchFramework", targets: ["PouchFramework"]) 10 | ], 11 | dependencies: [ 12 | .package(name: "swift-argument-parser", url: "https://github.com/apple/swift-argument-parser", .exact("0.3.2")), 13 | .package(name: "Yams", url: "https://github.com/jpsim/Yams", .exact("4.0.4")), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "Pouch", 18 | dependencies: [ 19 | .target(name: "PouchFramework"), 20 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 21 | .product(name: "Yams", package: "Yams") 22 | ]), 23 | .target( 24 | name: "PouchFramework", 25 | dependencies: [ 26 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 27 | ]), 28 | .testTarget(name: "PouchTests", dependencies: ["PouchFramework", "Yams"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Pouch 2 | Secret management tool written in Swift. This was heavily inspired by [CcocoaPods-Keys](https://github.com/orta/cocoapods-keys) & [NSHipster article regarding secret management](https://nshipster.com/secrets/). 3 | 4 | ## Usage 5 | 6 | Set up a config file for a project once: 7 | ```yaml 8 | secrets: 9 | - API_KEY 10 | - API_SECRET 11 | outputs: 12 | - ./Secrets.swift 13 | ``` 14 | 15 | Now, with `API_KEY` and `API_SECRET` stored in environment variables, you can generate a file with secrets using: 16 | ``` 17 | pouch retrieve 18 | ``` 19 | 20 | Which should generate an output similar to this one (applied `xor` on a string (+ randomly generated salt) with a reverse func to read it in the app)): 21 | ```swift 22 | import Foundation 23 | 24 | enum Secret { 25 | static let apiKey: String = Secret._xored([15, 26, 26, 243, 46, 124, 234, 140, 48, 169, 192], salt: [97, 115, 121, 150, 65, 18, 143, 225, 81, 221, 165, 134, 36, 222, 157, 20, 172, 203, 97, 8, 26, 81, 49, 144, 147, 1, 197, 21, 35, 32, 83, 156, 247, 108, 211, 108, 202, 174, 119, 134, 141, 176, 180, 38, 171, 110, 89, 21, 213, 32, 171, 146, 63, 245, 87, 139, 162, 194, 63, 57, 75, 0, 165, 122, 142]) 26 | static let apiSecret: String = Secret._xored([153, 59, 35, 31, 242, 106, 45, 3, 19, 67, 207, 9, 190, 40, 55, 197, 218, 221, 1, 40, 170, 117, 103, 211, 204, 168, 44, 18, 39, 207, 44, 158, 217, 135, 163, 16, 145, 120, 158, 221, 212, 49, 229, 116, 188, 145, 91, 203, 174, 184, 158, 78, 146, 106, 100, 166, 93, 239, 8, 18, 38, 129, 97, 249, 218, 137, 48, 58, 80, 252, 102, 47, 7, 92, 90, 194, 64, 61, 151, 221, 39], salt: [252, 85, 73, 112, 139, 3, 67, 100, 51, 55, 167, 96, 205, 8, 68, 168, 187, 177, 109, 8, 222, 26, 8, 191, 243, 136, 101, 50, 80, 160, 89, 242, 189, 167, 207, 127, 231, 29, 190, 174, 187, 92, 128, 84, 212, 244, 55, 187, 142, 207, 247, 58, 250, 74, 13, 210, 124, 207, 88, 64, 85, 174, 8, 138, 169]) 27 | 28 | private static func _xored(_ secret: [UInt8], salt: [UInt8]) -> String { 29 | return String(bytes: secret.enumerated().map { index, character in 30 | return character ^ salt[index % salt.count] 31 | }, encoding: .utf8) ?? "" 32 | } 33 | } 34 | ``` 35 |
36 | 37 | Now, add this file to your project structure (and to `.gitignore`) and use it!
38 | 39 | ```swift 40 | api.auth(key: Secret.apiKey, secret: Secret.apiSecret) 41 | ``` 42 | 43 | Note: The idea is that each developer would regenerate that file and not commit to the repository (however, you can use it however you want). 44 | 45 | ## Why? 46 | Let's face it - managing secret keys is not an easy task. We usually want: 47 | 1. Protect ourselves against unwanted intruders that gain access to the repository (plain text is bad and so is e.g. symetric cryptography that only needs the attacker to either run the code to get the keys or calculate the secret by hand) 48 | 2. Protect ourselves against unwanted access to binary through e.g. jailbreak (e.g. in iOS the binary will hold plain text keys even if you ignored them in git, storing them in `.xcconfig` makes it even easier to the attacker) 49 | 3. The secret management to be as simple as possible. 50 | 51 | While writing this tool there was nothing that I found that helped with all of the above. 52 | 53 | `pouch` will make sure that static analysis tools will not be able to get to the keys easily. Though, for your own good, do not commit this file to the repository. 54 | 55 | 56 | ## Configuration options 57 | The config is in [YAML](https://yaml.org/spec/1.2/spec.html) format. By default the tool will look for `.pouch.yml` file, but you can provide a custom file path as a parameter: 58 | ``` 59 | pouch retrieve --config ./.custom.pouch.yml 60 | ``` 61 | 62 | For the config itself, you are required to have at least one secret and one output: 63 | ```yaml 64 | secrets: 65 | - API_KEY 66 | outputs: 67 | - ./Secrets.swift 68 | ``` 69 | 70 | Though, there are also custom properties you can set. 71 | 72 | ### Generated type name 73 | You can change the generated type name (it's `Secrets` by default): 74 | ```yaml 75 | secrets: 76 | - API_KEY 77 | outputs: 78 | - filePath: ./Constants.swift 79 | typeName: Constant 80 | ``` 81 | 82 | ### Generated secret name 83 | You are also able to provide a custom generated name for a secret (otherwise it will do the `camelCase`): 84 | ```yaml 85 | secrets: 86 | - name: API_KEY 87 | generatedName: youtubeApiKey 88 | - API_SECRET 89 | outputs: 90 | - ./Secrets.swift 91 | ``` 92 | 93 | There are also things like custom inputs, but for now we only support environment variables. 94 | 95 | ## Installing 96 | You can either build & install it by using my Homebrew tap: 97 | ``` 98 | brew install sunshinejr/formulae/pouch 99 | ``` 100 | or by cloning the repo and using Make: 101 | ``` 102 | make install 103 | ``` 104 | 105 | ## Contributing 106 | This project is at its early stage and it currently only supports `xor` with random salt & only Swift output, but I'm open to: 107 | - Adding new ciphers (ideally something that works on all characters, e.g. `Caesar` is great but shifting might be problematic for things like emoji) 108 | - Adding new cipher options (e.g. salt length) 109 | - Adding new outputs (e.g. Kotlin, though I'd love to add only things that would be quite useful, not just a PoC) 110 | 111 | ## Notes about security 112 | While this is for sure an improvement to your normal, plain-text based flow, this doesn't guarantee that your keys won't be reverse-engineered. 113 | If you want to learn more about secret management and it's security, I recomend you to read the whole article I linked at the top of the Readme: [NSHipster article regarding secret management](https://nshipster.com/secrets/) 114 | 115 | ## Kudos 116 | - to [@nshipster](https://github.com/NSHipster) folks for awesome articles, especially on [secret management](https://nshipster.com/secrets/) & [Homebrew releases](https://nshipster.com/homebrew/),
117 | - to [@orta](https://github.com/orta) for [cocoapods-keys](https://github.com/orta/cocoapods-keys), 118 | - to [@yonaskolb](https://github.com/yonaskolb) and the whole team behind Mint for a [pretty nice Makefile template in Mint]() that I slightly modified,
119 | - to [@mxcl](https://github.com/mxcl) for a Swift clone of [Chalk](https://github.com/mxcl/Chalk) that Pouch uses for logging,
120 | - to [@jpsim](https://github.com/jpsim) and the contributors of [Yams](https://github.com/jpsim/Yams) for a yummy YAML parser that just works,
121 | - to [@natecook1000](https://github.com/natecook1000) and the contributors of [Swift Argument Parser](https://github.com/apple/swift-argument-parser) for an awesome experience that is building a modern command line tool
122 | 123 | ## License 124 | [Mit](License.md) 125 | -------------------------------------------------------------------------------- /Sources/Pouch/Commands/Pouch.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | 4 | public struct Pouch: ParsableCommand { 5 | public static var configuration = CommandConfiguration( 6 | abstract: "A utility tool for secret management", 7 | version: "0.2.0", 8 | subcommands: [Retrieve.self], 9 | defaultSubcommand: Retrieve.self 10 | ) 11 | 12 | public init() {} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Pouch/Commands/Retrieve.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import PouchFramework 4 | import Yams 5 | 6 | public struct Retrieve: ParsableCommand { 7 | public static let configuration = CommandConfiguration(abstract: "Retrieve secrets & generate files at given paths with given configuration.") 8 | 9 | @Option(help: "The config file used to generate the files.") 10 | var config: String = "./.pouch.yml" 11 | 12 | public init() {} 13 | 14 | public func run() throws { 15 | do { 16 | // Since we cannot extend YAMLDecoder to attach a logger, work around that by setting a global logger property. 17 | PouchFramework.logger = logger 18 | logger.log(.parser, "Reading \(config, color: .green)...") 19 | let config = try String(contentsOf: URL(fileURLWithPath: self.config)) 20 | let mappedConfig: Configuration = try YAMLDecoder().decode(from: config) 21 | logger.log(.parser, "\(self.config, color: .green) parsed successfully!") 22 | Engine().createFiles(configuration: mappedConfig) 23 | } catch { 24 | logger.log(.parser, "Error when parsing \(config, color: .blue): \(error, color: .red)") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Pouch/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import PouchFramework 4 | 5 | let logger = Logger(output: .print) 6 | 7 | Pouch.main() 8 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Chalk.swift: -------------------------------------------------------------------------------- 1 | /// Courtesy of @mxcl: https://github.com/mxcl/Chalk 2 | 3 | public enum Color: RawRepresentable { 4 | case black 5 | case red 6 | case green 7 | case yellow 8 | case blue 9 | case magenta 10 | case cyan 11 | case white 12 | case extended(UInt8) 13 | 14 | public init?(rawValue: UInt8) { 15 | switch rawValue { 16 | case 0: self = .black 17 | case 1: self = .red 18 | case 2: self = .green 19 | case 3: self = .yellow 20 | case 4: self = .blue 21 | case 5: self = .magenta 22 | case 6: self = .cyan 23 | case 7: self = .white 24 | default: self = .extended(rawValue) 25 | } 26 | } 27 | 28 | public var rawValue: UInt8 { 29 | switch self { 30 | case .black: return 0 31 | case .red: return 1 32 | case .green: return 2 33 | case .yellow: return 3 34 | case .blue: return 4 35 | case .magenta: return 5 36 | case .cyan: return 6 37 | case .white: return 7 38 | case .extended(let number): return number 39 | } 40 | } 41 | } 42 | 43 | public struct Style: OptionSet { 44 | public static let bold = Style(rawValue: 1 << 0) 45 | public static let dim = Style(rawValue: 1 << 1) 46 | public static let italic = Style(rawValue: 1 << 2) 47 | public static let underlined = Style(rawValue: 1 << 3) 48 | public static let blink = Style(rawValue: 1 << 4) 49 | public static let inverse = Style(rawValue: 1 << 5) 50 | public static let hidden = Style(rawValue: 1 << 6) // for eg. passwords 51 | public static let strikethrough = Style(rawValue: 1 << 7) // not implemented in Terminal.app 52 | public let rawValue: Int 53 | 54 | public init(rawValue: Int) { 55 | self.rawValue = rawValue 56 | } 57 | } 58 | 59 | private extension String.StringInterpolation { 60 | mutating func applyChalk(color: Color?, background: Color?, style: Style?, to any: Any) { 61 | appendLiteral("\u{001B}[") 62 | 63 | var codeStrings: [String] = [] 64 | 65 | if let color = color?.rawValue { 66 | codeStrings.append("38") 67 | codeStrings.append("5") 68 | codeStrings.append("\(color)") 69 | } 70 | 71 | if let background = background?.rawValue { 72 | codeStrings.append("48") 73 | codeStrings.append("5") 74 | codeStrings.append("\(background)") 75 | } 76 | 77 | if let style = style { 78 | let lookups: [(Style, Int)] = [(.bold, 1), (.dim, 2), (.italic, 3), (.underlined, 4), (.blink, 5), (.inverse, 7), (.hidden, 8), (.strikethrough, 9)] 79 | for (key, value) in lookups where style.contains(key) { 80 | codeStrings.append(String(value)) 81 | } 82 | } 83 | 84 | appendInterpolation(codeStrings.joined(separator: ";")) 85 | 86 | appendLiteral("m") 87 | appendInterpolation("\(any)") 88 | appendLiteral("\u{001B}[0m") // reset color, background, and style 89 | } 90 | } 91 | 92 | public extension String.StringInterpolation { 93 | mutating func appendInterpolation(_ any: Any, color: Color) { 94 | applyChalk(color: color, background: nil, style: nil, to: any) 95 | } 96 | 97 | mutating func appendInterpolation(_ any: Any, background: Color) { 98 | applyChalk(color: nil, background: background, style: nil, to: any) 99 | } 100 | 101 | mutating func appendInterpolation(_ any: Any, style: Style) { 102 | applyChalk(color: nil, background: nil, style: style, to: any) 103 | } 104 | 105 | mutating func appendInterpolation(_ any: Any, color: Color, background: Color) { 106 | applyChalk(color: color, background: background, style: nil, to: any) 107 | } 108 | 109 | mutating func appendInterpolation(_ any: Any, background: Color, style: Style) { 110 | applyChalk(color: nil, background: background, style: style, to: any) 111 | } 112 | 113 | mutating func appendInterpolation(_ any: Any, color: Color, style: Style) { 114 | applyChalk(color: color, background: nil, style: style, to: any) 115 | } 116 | 117 | mutating func appendInterpolation(_ any: Any, color: Color, background: Color, style: Style) { 118 | applyChalk(color: color, background: background, style: style, to: any) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Defaults.swift: -------------------------------------------------------------------------------- 1 | public enum Defaults { 2 | public static let encryption = Cipher.xor 3 | public static let input = Input.environmentVariable 4 | } 5 | 6 | public extension Defaults { 7 | enum Swift { 8 | public static let typeName = "Secrets" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Engine.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Engine { 4 | public init() {} 5 | 6 | public func createFiles(configuration: Configuration) { 7 | logger.log(.variableFetcher, "Resolving input variables...") 8 | resolve(declarations: configuration.secrets, input: configuration.input) { result in 9 | switch result { 10 | case let .success(secrets): 11 | for output in configuration.outputs { 12 | do { 13 | logger.log(.variableFetcher, "Input variables resolved successfully!") 14 | logger.log(.fileWriter, "Generating file output at \(output.file.filePath, color: .green)...") 15 | let contents = try generateFileContents(secrets: secrets, output: output, logger: logger) 16 | try write(fileContents: contents, to: output.file) 17 | logger.log(.fileWriter, "Generated file output at \(output.file.filePath, color: .green) successfully!") 18 | } catch { 19 | logger.log(.fileWriter, "Couldn't generate file output at \(output.file.filePath, color: .green): \(error, color: .red)") 20 | } 21 | } 22 | case let .failure(error): 23 | logger.log(.variableFetcher, "Couldn't retrieve input variables: \(error, color: .red)") 24 | } 25 | } 26 | } 27 | 28 | public func resolve(declarations: [SecretDeclaration], input: Input, completion: (Result<[Secret], Error>) -> Void) { 29 | switch input { 30 | case .environmentVariable: 31 | EnvironmentVariableFetcher().fetch(secrets: declarations, completion: completion) 32 | } 33 | } 34 | 35 | public func generateFileContents(secrets: [Secret], output: Output, logger: Logging) throws -> String { 36 | switch output.outputLanguage { 37 | case let .swift(swiftConfig): 38 | return SwiftGenerator().generateFileContents(secrets: secrets, config: swiftConfig) 39 | } 40 | } 41 | 42 | private func write(fileContents contents: String, to file: DecryptionFile) throws { 43 | let url = URL(fileURLWithPath: file.filePath) 44 | try contents.write(to: url, atomically: true, encoding: .utf8) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Extensions/Collection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | var isNotEmpty: Bool { 5 | return !isEmpty 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Extensions/Sequence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Sequence { 4 | /// Beware! This isn't requiring the Hashable protocol for Element, 5 | /// and so finding unique element with this helper method is quite complex - O(n*n) 6 | func unique(identifying: (Element, Element) -> Bool) -> [Element] { 7 | var results = [Element]() 8 | for item in self where !results.contains(where: { identifying($0, item) }) { 9 | results.append(item) 10 | } 11 | return results 12 | } 13 | } 14 | 15 | extension Sequence where Element: Hashable { 16 | func unique() -> [Element] { 17 | return Array(Set(self)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | func uppercaseFirstLetter() -> String { 5 | guard isNotEmpty else { return self } 6 | 7 | return prefix(1).uppercased() + dropFirst().lowercased() 8 | } 9 | 10 | func toCamelCase() -> String { 11 | let parts = components(separatedBy: CharacterSet.alphanumerics.inverted) 12 | 13 | guard let firstPart = parts.first?.lowercased() else { return self } 14 | 15 | let otherParts = Array(parts.dropFirst().map { $0.uppercaseFirstLetter() }) 16 | 17 | return [[firstPart], otherParts].flatMap { $0 }.joined(separator: "") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Extensions/UInt8.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Array where Element == UInt8 { 4 | static func random(length: Int) -> [UInt8] { 5 | return (0...length).map { _ in UInt8.random(in: UInt8.min...UInt8.max) } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Extensions/URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | public static var documentsDirectory: URL { 5 | return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 6 | } 7 | 8 | public static var temporaryDirectory: URL { 9 | if #available(OSX 10.12, *) { 10 | return FileManager.default.temporaryDirectory 11 | } else { 12 | return documentsDirectory 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Generators/Swift/SwiftCipherContentsGenerating.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SwiftCipherContentsGenerating { 4 | func variableValue(for secret: Secret, config: SwiftConfig) -> String 5 | func neededImports() -> [String] 6 | func neededHelperFunctions() -> [String] 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Generators/Swift/SwiftGenerator.swift: -------------------------------------------------------------------------------- 1 | public struct SwiftGenerator { 2 | private struct SecretVariable { 3 | let name: String 4 | let type: String 5 | let value: String 6 | 7 | func toFullDeclaration() -> String { 8 | return "static let \(name): \(type) = \(value)" 9 | } 10 | } 11 | 12 | public init() {} 13 | 14 | public func generateFileContents(secrets: [Secret], config: SwiftConfig) -> String { 15 | var imports = [String]() 16 | var functions = [String]() 17 | var variables = [SecretVariable]() 18 | 19 | for secret in secrets { 20 | let cipher = cipherGenerator(for: secret) 21 | let value = cipher.variableValue(for: secret, config: config) 22 | let variable = SecretVariable( 23 | name: secret.generatedName ?? secret.name.toCamelCase(), 24 | type: "String", 25 | value: value 26 | ) 27 | 28 | imports.append(contentsOf: cipher.neededImports()) 29 | functions.append(contentsOf: cipher.neededHelperFunctions()) 30 | variables.append(variable) 31 | } 32 | 33 | return 34 | """ 35 | // swiftlint:disable all 36 | // Generated using Pouch — https://github.com/sunshinejr/Pouch 37 | 38 | \(imports.unique().map { "import \($0)" }.joined(separator: "\n")) 39 | 40 | enum \(config.typeName) { 41 | \(variables.map { " " + $0.toFullDeclaration() }.joined(separator: "\n")) 42 | 43 | \(functions.unique().joined(separator: "\n\n")) 44 | }\n 45 | """ 46 | } 47 | 48 | private func cipherGenerator(for secret: Secret) -> SwiftCipherContentsGenerating { 49 | switch secret.encryption { 50 | case .xor: 51 | return SwiftXorGenerator() 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Generators/Swift/SwiftXorGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct SwiftXorGenerator: SwiftCipherContentsGenerating { 4 | public func variableValue(for secret: Secret, config: SwiftConfig) -> String { 5 | let salt = [UInt8].random(length: 64) 6 | let xoredValue = xorEncode(secret: secret.value, salt: salt) 7 | return "\(config.typeName)._xored(\(xoredValue), salt: \(salt))" 8 | } 9 | 10 | public func neededHelperFunctions() -> [String] { 11 | return 12 | [""" 13 | private static func _xored(_ secret: [UInt8], salt: [UInt8]) -> String { 14 | return String(bytes: secret.enumerated().map { index, character in 15 | return character ^ salt[index % salt.count] 16 | }, encoding: .utf8) ?? "" 17 | } 18 | """] 19 | } 20 | 21 | public func neededImports() -> [String] { 22 | return ["Foundation"] 23 | } 24 | 25 | private func xorEncode(secret: String, salt: [UInt8]) -> [UInt8] { 26 | let secretBytes = [UInt8](secret.utf8) 27 | return secretBytes.enumerated().map { index, character in 28 | return character ^ salt[index % salt.count] 29 | } 30 | } 31 | 32 | private func xorDecode(encoded secretBytes: [UInt8], salt: [UInt8]) -> String { 33 | return String(bytes: secretBytes.enumerated().map { index, character in 34 | return character ^ salt[index % salt.count] 35 | }, encoding: .utf8) ?? "" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public var logger: Logging = Logger(output: .disabled) 4 | 5 | public extension LogCategory { 6 | static let parser = LogCategory(name: "Parser") 7 | static let variableFetcher = LogCategory(name: "VariableFetcher") 8 | static let fileWriter = LogCategory(name: "FileWriter") 9 | } 10 | 11 | public protocol Logging { 12 | func log(_ message: String) 13 | func log(_ category: LogCategory, _ message: String) 14 | } 15 | 16 | public struct LogCategory { 17 | public let name: String 18 | 19 | public init(name: String) { 20 | self.name = name 21 | } 22 | } 23 | 24 | public struct Logger: Logging { 25 | public enum Output { 26 | case print 27 | case disabled 28 | } 29 | 30 | public var output: Output 31 | 32 | public init(output: Output) { 33 | self.output = output 34 | } 35 | 36 | public func log(_ category: LogCategory, _ message: String) { 37 | _log(category: category, message) 38 | } 39 | 40 | public func log(_ message: String) { 41 | _log(message) 42 | } 43 | 44 | private func _log(category: LogCategory? = nil, _ message: String) { 45 | switch output { 46 | case .print: 47 | if let category = category { 48 | print("\("[\(category.name)]", color: .yellow) \(message)") 49 | } else { 50 | print("\(message)") 51 | } 52 | case .disabled: 53 | break 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/Cipher.swift: -------------------------------------------------------------------------------- 1 | public enum Cipher: String, Codable, Equatable { 2 | case xor 3 | } 4 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/Configuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Configuration: Codable, Equatable { 4 | public let input: Input 5 | public let secrets: [SecretDeclaration] 6 | public let outputs: [Output] 7 | 8 | public init(input: Input, secrets: [SecretDeclaration], outputs: [Output]) { 9 | self.input = input 10 | self.secrets = secrets 11 | self.outputs = outputs 12 | } 13 | 14 | public init(from decoder: Decoder) throws { 15 | let container = try decoder.container(keyedBy: CodingKeys.self) 16 | self.input = (try container.decodeIfPresent(Input.self, forKey: .input)) ?? Defaults.input 17 | self.secrets = try container.decode([SecretDeclaration].self, forKey: .secrets) 18 | self.outputs = try container.decode([Output].self, forKey: .outputs) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/DecryptionFile.swift: -------------------------------------------------------------------------------- 1 | public struct DecryptionFile: Codable, Equatable { 2 | public let filePath: String 3 | 4 | enum CodingKeys: String, CodingKey { 5 | case filePath 6 | } 7 | 8 | public init(filePath: String) { 9 | self.filePath = filePath 10 | } 11 | 12 | public init(from decoder: Decoder) throws { 13 | if let container = try? decoder.singleValueContainer(), let filePath = try? container.decode(String.self) { 14 | self.init(filePath: filePath) 15 | } else { 16 | let container = try decoder.container(keyedBy: CodingKeys.self) 17 | let filePath = try container.decode(String.self, forKey: .filePath) 18 | self.init(filePath: filePath) 19 | } 20 | } 21 | 22 | public func encode(to encoder: Encoder) throws { 23 | var container = encoder.singleValueContainer() 24 | try container.encode(filePath) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/Input.swift: -------------------------------------------------------------------------------- 1 | public enum Input: String, Codable, Equatable { 2 | case environmentVariable 3 | 4 | enum CodingKeys: String, CodingKey { 5 | case environmentVariable = "env" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/LanguageOutput.swift: -------------------------------------------------------------------------------- 1 | enum OutputLanguageError: Error { 2 | case languageNotSupported(String) 3 | } 4 | 5 | public enum OutputLanguage: Codable, Equatable { 6 | case swift(SwiftConfig) 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case language 10 | case config 11 | } 12 | 13 | public init(from decoder: Decoder) throws { 14 | if let container = try? decoder.container(keyedBy: CodingKeys.self) { 15 | let language = try container.decodeIfPresent(String.self, forKey: .language) 16 | 17 | switch language { 18 | case "swift": 19 | let config = try SwiftConfig(from: decoder) 20 | self = .swift(config) 21 | default: 22 | if let language = language, language.isNotEmpty { 23 | logger.log(.parser, "Language \"\(language)\" not supported, falling back to \"swift\"") 24 | } 25 | } 26 | } 27 | 28 | let config = try SwiftConfig(from: decoder) 29 | self = .swift(config) 30 | } 31 | 32 | public func encode(to encoder: Encoder) throws { 33 | var container = encoder.container(keyedBy: CodingKeys.self) 34 | 35 | switch self { 36 | case let .swift(config): 37 | try container.encode("swift", forKey: .language) 38 | try config.encode(to: encoder) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/Languages/SwiftConfig.swift: -------------------------------------------------------------------------------- 1 | public struct SwiftConfig: Codable, Equatable { 2 | public let typeName: String 3 | 4 | enum CodingKeys: String, CodingKey { 5 | case typeName 6 | } 7 | 8 | public init(typeName: String) { 9 | self.typeName = typeName 10 | } 11 | 12 | public init(from decoder: Decoder) throws { 13 | if let container = try? decoder.container(keyedBy: CodingKeys.self) { 14 | self.typeName = (try? container.decode(String.self, forKey: .typeName)) ?? Defaults.Swift.typeName 15 | } else { 16 | self.typeName = Defaults.Swift.typeName 17 | } 18 | } 19 | 20 | public func encode(to encoder: Encoder) throws { 21 | var container = encoder.container(keyedBy: CodingKeys.self) 22 | try container.encode(typeName, forKey: .typeName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/Output.swift: -------------------------------------------------------------------------------- 1 | public struct Output: Codable, Equatable { 2 | public let file: DecryptionFile 3 | public let outputLanguage: OutputLanguage 4 | 5 | public init(decryptionFile: DecryptionFile, outputLanguage: OutputLanguage) { 6 | self.file = decryptionFile 7 | self.outputLanguage = outputLanguage 8 | } 9 | 10 | public init(from decoder: Decoder) throws { 11 | file = try DecryptionFile(from: decoder) 12 | outputLanguage = try OutputLanguage(from: decoder) 13 | } 14 | 15 | public func encode(to encoder: Encoder) throws { 16 | try file.encode(to: encoder) 17 | try outputLanguage.encode(to: encoder) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/Secret.swift: -------------------------------------------------------------------------------- 1 | public struct Secret { 2 | public let name: String 3 | public let generatedName: String? 4 | public let value: String 5 | public let encryption: Cipher 6 | 7 | public init(name: String, generatedName: String?, value: String, encryption: Cipher) { 8 | self.name = name 9 | self.generatedName = generatedName 10 | self.value = value 11 | self.encryption = encryption 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/PouchFramework/Models/SecretDeclaration.swift: -------------------------------------------------------------------------------- 1 | public struct SecretDeclaration: Codable, Equatable { 2 | public let name: String 3 | public let generatedName: String? 4 | public let encryption: Cipher 5 | 6 | enum CodingKeys: String, CodingKey { 7 | case name 8 | case generatedName 9 | case encryption 10 | } 11 | 12 | public init(name: String, generatedName: String? = nil, encryption: Cipher) { 13 | self.name = name 14 | self.generatedName = generatedName 15 | self.encryption = encryption 16 | } 17 | 18 | public init(from decoder: Decoder) throws { 19 | if let container = try? decoder.singleValueContainer(), let name = try? container.decode(String.self) { 20 | self.init(name: name, encryption: Defaults.encryption) 21 | } else { 22 | let container = try decoder.container(keyedBy: CodingKeys.self) 23 | let name = try container.decode(String.self, forKey: .name) 24 | let generatedName = try container.decodeIfPresent(String.self, forKey: .generatedName) 25 | let encryption = (try container.decodeIfPresent(Cipher.self, forKey: .encryption)) ?? Defaults.encryption 26 | self.init(name: name, generatedName: generatedName, encryption: encryption) 27 | } 28 | } 29 | 30 | public func encode(to encoder: Encoder) throws { 31 | if encryption == Defaults.encryption { 32 | var container = encoder.singleValueContainer() 33 | try container.encode(name) 34 | } else { 35 | var container = encoder.container(keyedBy: CodingKeys.self) 36 | try container.encode(name, forKey: .name) 37 | try container.encodeIfPresent(generatedName, forKey: .generatedName) 38 | try container.encode(encryption, forKey: .encryption) 39 | } 40 | } 41 | } 42 | 43 | extension SecretDeclaration { 44 | func with(value: String) -> Secret { 45 | return .init(name: name, generatedName: generatedName, value: value, encryption: encryption) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/PouchFramework/VariableFetchers/EnvironmentVariablesFetcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct EnvironmentVariableFetcher: VariableFetching { 4 | public func fetch(secrets: [SecretDeclaration], completion: (Result<[Secret], Error>) -> Void) { 5 | let environment = ProcessInfo.processInfo.environment 6 | var resolvedSecrets = [Secret]() 7 | for secret in secrets { 8 | guard let value = environment[secret.name] else { 9 | completion(.failure(VariableFetchingError.variableNotFound(name: secret.name, input: .environmentVariable))) 10 | return 11 | } 12 | 13 | resolvedSecrets.append(secret.with(value: value)) 14 | } 15 | 16 | completion(.success(resolvedSecrets)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/PouchFramework/VariableFetchers/VariableFetching.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol VariableFetching { 4 | func fetch(secrets: [SecretDeclaration], completion: (Result<[Secret], Error>) -> Void) 5 | } 6 | -------------------------------------------------------------------------------- /Sources/PouchFramework/VariableFetchers/VariableFetchingError.swift: -------------------------------------------------------------------------------- 1 | enum VariableFetchingError: Error { 2 | case variableNotFound(name: String, input: Input) 3 | } 4 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import PouchTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += PouchTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/PouchTests/Generators/SwiftGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PouchFramework 3 | 4 | final class SwiftGeneratorTests: XCTestCase { 5 | func test_generatedOutput() throws { 6 | let secret = Secret(name: "API_KEYY", generatedName: "apiKey", value: "secret_sauce_monke_boi🐒", encryption: .xor) 7 | let config = SwiftConfig(typeName: "Sauce") 8 | let contents = SwiftGenerator().generateFileContents(secrets: [secret], config: config) 9 | let contentsWithPrints = contents + "\n print(\(config.typeName).\(secret.generatedName!))" 10 | let file = try contentsWithPrints.saveToTemporaryDirectory() 11 | let output = try Process.run(tool: .swift, arguments: [file.path]) 12 | 13 | XCTAssertEqual(output, secret.value) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/PouchTests/Parsers/ConfigurationParsingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PouchFramework 3 | import Yams 4 | 5 | final class ConfigurationParsingTests: XCTestCase { 6 | func test_noOutput_noConfiguration() { 7 | let config = 8 | """ 9 | secrets: 10 | - API_KEY 11 | """ 12 | let parsedConfiguration = try? YAMLDecoder().decode(Configuration.self, from: Data(config.utf8)) 13 | XCTAssertNil(parsedConfiguration) 14 | } 15 | 16 | func test_noSecrets_noConfiguration() { 17 | let config = 18 | """ 19 | outputs: 20 | - ./Secrets.swift 21 | """ 22 | let parsedConfiguration = try? YAMLDecoder().decode(Configuration.self, from: Data(config.utf8)) 23 | XCTAssertNil(parsedConfiguration) 24 | } 25 | 26 | func test_hasAtLeastOneSecretAndOneOutput_parsesSuccessfully() { 27 | let config = 28 | """ 29 | secrets: 30 | - API_KEY 31 | outputs: 32 | - ./Secrets.swift 33 | """ 34 | let parsedConfiguration = try? YAMLDecoder().decode(Configuration.self, from: Data(config.utf8)) 35 | let expectedConfiguration = Configuration( 36 | input: Defaults.input, 37 | secrets: [ 38 | .init(name: "API_KEY", encryption: Defaults.encryption) 39 | ], 40 | outputs: [ 41 | .init(decryptionFile: .init(filePath: "./Secrets.swift"), outputLanguage: .swift(.init(typeName: Defaults.Swift.typeName))) 42 | ]) 43 | XCTAssertEqual(parsedConfiguration, expectedConfiguration) 44 | } 45 | } 46 | 47 | extension ConfigurationParsingTests { 48 | static var allTests = [ 49 | ("test_noOutput_noConfiguration", test_noOutput_noConfiguration), 50 | ("test_noSecrets_noConfiguration", test_noSecrets_noConfiguration), 51 | ("test_hasAtLeastOneSecretAndOneOutput_parsesSuccessfully", test_hasAtLeastOneSecretAndOneOutput_parsesSuccessfully) 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /Tests/PouchTests/Parsers/OutputParsingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PouchFramework 3 | import Yams 4 | 5 | final class OutputParsingTests: XCTestCase { 6 | func test_namedParameters_withFilePathOnly_parsesSuccessfully() { 7 | let config = 8 | """ 9 | - filePath: ./Secrets.swift 10 | """ 11 | let parsedOutputs = try? YAMLDecoder().decode([Output].self, from: Data(config.utf8)) 12 | let expectedOutputs = [ 13 | Output(decryptionFile: .init(filePath: "./Secrets.swift"), outputLanguage: .swift(.init(typeName: Defaults.Swift.typeName))) 14 | ] 15 | XCTAssertEqual(parsedOutputs, expectedOutputs) 16 | } 17 | 18 | func test_namedParameters_withFilePathAndTypeName_parsesSuccessfully() { 19 | let config = 20 | """ 21 | - filePath: ./Sauce.swift 22 | typeName: Sauce 23 | """ 24 | let parsedOutputs = try? YAMLDecoder().decode([Output].self, from: Data(config.utf8)) 25 | let expectedOutputs = [ 26 | Output(decryptionFile: .init(filePath: "./Sauce.swift"), outputLanguage: .swift(.init(typeName: "Sauce"))) 27 | ] 28 | XCTAssertEqual(parsedOutputs, expectedOutputs) 29 | } 30 | 31 | func test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully() { 32 | let config = 33 | """ 34 | - ./Secrets.swift 35 | - filePath: ./Sauce.swift 36 | typeName: Sauce 37 | """ 38 | let parsedOutputs = try? YAMLDecoder().decode([Output].self, from: Data(config.utf8)) 39 | let expectedOutputs = [ 40 | Output(decryptionFile: .init(filePath: "./Secrets.swift"), outputLanguage: .swift(.init(typeName: Defaults.Swift.typeName))), 41 | Output(decryptionFile: .init(filePath: "./Sauce.swift"), outputLanguage: .swift(.init(typeName: "Sauce"))) 42 | ] 43 | XCTAssertEqual(parsedOutputs, expectedOutputs) 44 | } 45 | } 46 | 47 | extension OutputParsingTests { 48 | static var allTests = [ 49 | ("test_namedParameters_withFilePathOnly_parsesSuccessfully", test_namedParameters_withFilePathOnly_parsesSuccessfully), 50 | ("test_namedParameters_withFilePathAndTypeName_parsesSuccessfully", test_namedParameters_withFilePathAndTypeName_parsesSuccessfully), 51 | ("test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully", test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully) 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /Tests/PouchTests/Parsers/SecretParsingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PouchFramework 3 | import Yams 4 | 5 | final class SecretParsingTests: XCTestCase { 6 | func test_namedParameters_withNameOnly_parsesSuccessfully() { 7 | let config = 8 | """ 9 | - name: API_KEY2 10 | """ 11 | let parsedSecrets = try? YAMLDecoder().decode([SecretDeclaration].self, from: Data(config.utf8)) 12 | let expectedSecrets = [SecretDeclaration(name: "API_KEY2", encryption: Defaults.encryption)] 13 | XCTAssertEqual(parsedSecrets, expectedSecrets) 14 | } 15 | 16 | 17 | func test_namedParameters_withNameAndGeneratedName_parsesSuccessfully() { 18 | let config = 19 | """ 20 | - name: API_KEY2 21 | generatedName: apiKeyuu 22 | """ 23 | let parsedSecrets = try? YAMLDecoder().decode([SecretDeclaration].self, from: Data(config.utf8)) 24 | let expectedSecrets = [SecretDeclaration(name: "API_KEY2", generatedName: "apiKeyuu", encryption: Defaults.encryption)] 25 | XCTAssertEqual(parsedSecrets, expectedSecrets) 26 | } 27 | 28 | func test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully() { 29 | let config = 30 | """ 31 | - API_KEY 32 | - name: API_KEY2 33 | generatedName: apiKeyuu 34 | """ 35 | let parsedSecrets = try? YAMLDecoder().decode([SecretDeclaration].self, from: Data(config.utf8)) 36 | let expectedSecrets = [ 37 | SecretDeclaration(name: "API_KEY", encryption: Defaults.encryption), 38 | SecretDeclaration(name: "API_KEY2", generatedName: "apiKeyuu", encryption: Defaults.encryption) 39 | ] 40 | XCTAssertEqual(parsedSecrets, expectedSecrets) 41 | } 42 | } 43 | 44 | extension SecretParsingTests { 45 | static var allTests = [ 46 | ("test_namedParameters_withNameOnly_parsesSuccessfully", test_namedParameters_withNameOnly_parsesSuccessfully), 47 | ("test_namedParameters_withNameAndGeneratedName_parsesSuccessfully", test_namedParameters_withNameAndGeneratedName_parsesSuccessfully), 48 | ("test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully", test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully) 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /Tests/PouchTests/PouchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PouchFramework 3 | 4 | final class PouchTests: XCTestCase { 5 | func test_showsHelp() throws { 6 | let output = try Process.run(tool: .pouch, arguments: ["-h"]) 7 | XCTAssertTrue(output.contains("A utility tool for secret management")) 8 | } 9 | 10 | func test_generatesFileBasedOnConfig() throws { 11 | let secretApiKey = "secret_sauce_monke_boi🐒" 12 | let generatedFileUrl = URL(fileURLWithPath: "./Secrets2.swift") 13 | let config = """ 14 | secrets: 15 | - name: API_KEY_4 16 | generatedName: apiKey4 17 | outputs: 18 | - filePath: ./Secrets2.swift 19 | typeName: Sauce 20 | """ 21 | let configFile = try config.saveToTemporaryDirectory() 22 | try Process.run(tool: .pouch, arguments: ["retrieve", "--config", configFile.path], environmentVariables: ["API_KEY_4": secretApiKey]) 23 | let generatedFileContents = try String(contentsOfFile: generatedFileUrl.path) 24 | let generatedFileContentsWithPrint = generatedFileContents + "\n print(Sauce.apiKey4)" 25 | try generatedFileContentsWithPrint.write(toFile: generatedFileUrl.path, atomically: true, encoding: .utf8) 26 | 27 | let output = try Process.run(tool: .swift, arguments: [generatedFileUrl.path]) 28 | XCTAssertEqual(output, secretApiKey) 29 | } 30 | 31 | static var allTests = [ 32 | ("test_showHelpOnMainCall", test_showsHelp), 33 | ("test_generatesFileBasedOnConfig", test_generatesFileBasedOnConfig) 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /Tests/PouchTests/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | /// Returns path to the built products directory. 5 | public static var productsDirectory: URL { 6 | #if os(macOS) 7 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 8 | return bundle.bundleURL.deletingLastPathComponent() 9 | } 10 | fatalError("couldn't find the products directory") 11 | #else 12 | return Bundle.main.bundleURL 13 | #endif 14 | } 15 | } 16 | 17 | extension String { 18 | func removing(suffix: String) -> String { 19 | guard hasSuffix(suffix) else { return self } 20 | 21 | let endIndex = index(self.endIndex, offsetBy: -suffix.count) 22 | return String(self[.. URL { 26 | let url = URL.documentsDirectory.appendingPathComponent("Temp\(Int.random(in: 0...10_000))") 27 | try write(to: url, atomically: true, encoding: .utf8) 28 | return url 29 | } 30 | } 31 | 32 | enum Tool { 33 | case swift 34 | case pouch 35 | 36 | var executableURL: URL { 37 | switch self { 38 | case .swift: 39 | return URL(fileURLWithPath: "/usr/bin/swift") 40 | case .pouch: 41 | return URL.productsDirectory.appendingPathComponent("Pouch") 42 | } 43 | } 44 | } 45 | 46 | extension Process { 47 | @discardableResult 48 | static func run(tool: Tool, arguments: [String]? = nil, environmentVariables: [String: String]? = nil) throws -> String { 49 | let process = Process() 50 | process.executableURL = tool.executableURL 51 | process.environment = ProcessInfo.processInfo.environment 52 | for (key, value) in environmentVariables ?? [:] { 53 | process.environment?[key] = value 54 | } 55 | process.arguments = arguments 56 | 57 | let pipe = Pipe() 58 | process.standardOutput = pipe 59 | 60 | try process.run() 61 | process.waitUntilExit() 62 | 63 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 64 | return (String(data: data, encoding: .utf8) ?? "").removing(suffix: "\n") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/PouchTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(PouchTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------