From 7ab855e4e95d1d0e7f1040f978fa360091aee696 Mon Sep 17 00:00:00 2001 From: Manfred Endres Date: Wed, 26 Aug 2020 15:55:41 +0200 Subject: [PATCH] Initial commit --- .dependabot/config.yml | 8 + .gitignore | 17 ++ Jenkinsfile | 32 ++ LICENSE | 201 +++++++++++++ README.md | 65 ++++ RELEASE_NOTES.md | 16 + build.gradle | 51 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54706 bytes gradle/wrapper/gradle-wrapper.properties | 22 ++ gradle_check_versions.sh | 15 + gradlew | 172 +++++++++++ gradlew.bat | 84 ++++++ settings.gradle | 24 ++ .../gradle/secrets/IntegrationSpec.groovy | 107 +++++++ .../FetchSecretsTaskIntegrationSpec.groovy | 282 ++++++++++++++++++ .../tasks/SecretSpecIntegrationSpec.groovy | 103 +++++++ .../gradle/secrets/BasicAWSCredentials.groovy | 40 +++ .../gradle/secrets/EncryptedSecret.groovy | 26 ++ .../groovy/wooga/gradle/secrets/Secret.groovy | 21 ++ .../gradle/secrets/SecretResolver.groovy | 22 ++ .../secrets/SecretResolverException.groovy | 24 ++ .../wooga/gradle/secrets/SecretSpec.groovy | 33 ++ .../wooga/gradle/secrets/SecretsConsts.groovy | 25 ++ .../wooga/gradle/secrets/SecretsPlugin.groovy | 58 ++++ .../secrets/SecretsPluginExtension.groovy | 23 ++ .../internal/AWSSecretsManagerResolver.groovy | 62 ++++ .../internal/AbstractEncryptedSecret.groovy | 72 +++++ .../secrets/internal/DefaultResolver.groovy | 38 +++ .../secrets/internal/DefaultSecret.groovy | 39 +++ .../DefaultSecretsPluginExtension.groovy | 97 ++++++ .../internal/EncryptedSecretFile.groovy | 35 +++ .../internal/EncryptedSecretText.groovy | 34 +++ .../internal/EnvironmentResolver.groovy | 38 +++ .../internal/MemoisationProvider.groovy | 43 +++ .../gradle/secrets/internal/Resolver.groovy | 36 +++ .../gradle/secrets/internal/SecretFile.groovy | 23 ++ .../internal/SecretResolverChain.groovy | 70 +++++ .../gradle/secrets/internal/SecretText.groovy | 23 ++ .../gradle/secrets/internal/Secrets.groovy | 118 ++++++++ .../gradle/secrets/tasks/FetchSecrets.groovy | 137 +++++++++ .../net.wooga.secrets.properties | 17 ++ .../SecretsPluginActivationSpec.groovy | 24 ++ .../gradle/secrets/SecretsPluginSpec.groovy | 63 ++++ .../AWSSecretsManagerResolverSpec.groovy | 82 +++++ .../internal/EncryptedSecretFileSpec.groovy | 44 +++ .../internal/EncryptedSecretSpec.groovy | 84 ++++++ .../internal/EncryptedSecretTextSpec.groovy | 36 +++ .../internal/EncryptionSpecHelper.groovy | 36 +++ .../internal/EnvironmentResolverSpec.groovy | 54 ++++ .../internal/SecretResolverChainSpec.groovy | 141 +++++++++ .../gradle/secrets/internal/SecretSpec.groovy | 35 +++ .../internal/SecretsResolverSpec.groovy | 69 +++++ .../secrets/internal/SecretsSpec.groovy | 183 ++++++++++++ 53 files changed, 3204 insertions(+) create mode 100644 .dependabot/config.yml create mode 100755 .gitignore create mode 100755 Jenkinsfile create mode 100755 LICENSE create mode 100755 README.md create mode 100755 RELEASE_NOTES.md create mode 100755 build.gradle create mode 100755 gradle/wrapper/gradle-wrapper.jar create mode 100755 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradle_check_versions.sh create mode 100755 gradlew create mode 100755 gradlew.bat create mode 100755 settings.gradle create mode 100755 src/integrationTest/groovy/wooga/gradle/secrets/IntegrationSpec.groovy create mode 100644 src/integrationTest/groovy/wooga/gradle/secrets/tasks/FetchSecretsTaskIntegrationSpec.groovy create mode 100644 src/integrationTest/groovy/wooga/gradle/secrets/tasks/SecretSpecIntegrationSpec.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/BasicAWSCredentials.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/EncryptedSecret.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/Secret.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/SecretResolver.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/SecretResolverException.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/SecretSpec.groovy create mode 100755 src/main/groovy/wooga/gradle/secrets/SecretsConsts.groovy create mode 100755 src/main/groovy/wooga/gradle/secrets/SecretsPlugin.groovy create mode 100755 src/main/groovy/wooga/gradle/secrets/SecretsPluginExtension.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolver.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/AbstractEncryptedSecret.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/DefaultResolver.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/DefaultSecret.groovy create mode 100755 src/main/groovy/wooga/gradle/secrets/internal/DefaultSecretsPluginExtension.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretFile.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretText.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/EnvironmentResolver.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/MemoisationProvider.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/Resolver.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/SecretFile.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/SecretResolverChain.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/SecretText.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/internal/Secrets.groovy create mode 100644 src/main/groovy/wooga/gradle/secrets/tasks/FetchSecrets.groovy create mode 100755 src/main/resources/META-INF/gradle-plugins/net.wooga.secrets.properties create mode 100755 src/test/groovy/wooga/gradle/secrets/SecretsPluginActivationSpec.groovy create mode 100755 src/test/groovy/wooga/gradle/secrets/SecretsPluginSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolverSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretFileSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretTextSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/EncryptionSpecHelper.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/EnvironmentResolverSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/SecretResolverChainSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/SecretSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/SecretsResolverSpec.groovy create mode 100644 src/test/groovy/wooga/gradle/secrets/internal/SecretsSpec.groovy diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 0000000..729b475 --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,8 @@ +version: 1 +update_configs: + - package_manager: "java:gradle" + directory: "/" + update_schedule: "daily" + ignored_updates: + - match: + dependency_name: "org.spockframework:spock-core" diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..5741b6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.gradle +.idea +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties +userHome/ +out diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100755 index 0000000..18c4ac2 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,32 @@ +#!groovy +/* + * Copyright 2018-2020 Wooga GmbH + * + * 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. + * + * + * + */ + +@Library('github.com/wooga/atlas-jenkins-pipeline@1.x') _ + +withCredentials([ + string(credentialsId: 'atlas_secrets_coveralls_token', variable: 'coveralls_token'), + string(credentialsId: 'aws.secretsmanager.integration.accesskey', variable: 'accesskey'), + string(credentialsId: 'aws.secretsmanager.integration.secretkey', variable: 'secretkey'), + ]) +{ + def env = ["ATLAS_AWS_INTEGRATION_ACCESS_KEY=${accesskey}", "ATLAS_AWS_INTEGRATION_SECRET_KEY=${secretkey}"] + buildGradlePlugin plaforms: ['osx','windows', 'linux'], coverallsToken: coveralls_token, testEnvironment:env +} + diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..06a1e57 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 2018 Wooga GmbH + + 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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..5b77c78 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +atlas-secrets +============ + +[![Gradle Plugin ID](https://img.shields.io/badge/gradle-net.wooga.github-brightgreen.svg?style=flat-square)](https://plugins.gradle.org/plugin/net.wooga.github) +[![Build Status](https://img.shields.io/travis/wooga/atlas-secrets/master.svg?style=flat-square)](https://travis-ci.org/wooga/atlas-secrets) +[![Coveralls Status](https://img.shields.io/coveralls/wooga/atlas-secrets/master.svg?style=flat-square)](https://coveralls.io/github/wooga/atlas-secrets?branch=master) +[![Apache 2.0](https://img.shields.io/badge/license-Apache%202-blue.svg?style=flat-square)](https://raw.githubusercontent.com/wooga/atlas-secrets/master/LICENSE) +[![GitHub tag](https://img.shields.io/github/tag/wooga/atlas-secrets.svg?style=flat-square)]() +[![GitHub release](https://img.shields.io/github/release/wooga/atlas-secrets.svg?style=flat-square)]() + +This plugin provides a generic secrets resolver implementation to fetch secrets from external credentials stores. + +# Applying the plugin + +**build.gradle** +```groovy +plugins { + id 'net.wooga.secrets' version '0.1.0' +} +``` + +Documentation +============= + +- [API docs](https://wooga.github.io/atlas-secrets/docs/api/) + +Gradle and Java Compatibility +============================= + +| Gradle Version | Works | +| :------------- | :---------: | +| < 4.0 | ![no] | +| 4.0 | ![yes] | +| 4.1 | ![yes] | +| 4.2 | ![yes] | +| 4.3 | ![yes] | +| 4.4 | ![yes] | +| 4.5 | ![yes] | +| 4.6 | ![yes] | +| 4.7 | ![yes] | +| 4.8 | ![yes] | +| 4.9 | ![yes] | +| 4.10 | ![yes] | + +Development +=========== + +[Code of Conduct](docs/Code-of-conduct.md) + +LICENSE +======= + +Copyright 2020 Wooga GmbH + +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 + + + +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. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100755 index 0000000..da2d5a6 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,16 @@ + + +[NEW]:http://atlas-resources.wooga.com/icons/icon_new.svg "New" +[ADD]:http://atlas-resources.wooga.com/icons/icon_add.svg "Add" +[IMPROVE]:http://atlas-resources.wooga.com/icons/icon_improve.svg "IMPROVE" +[CHANGE]:http://atlas-resources.wooga.com/icons/icon_change.svg "Change" +[FIX]:http://atlas-resources.wooga.com/icons/icon_fix.svg "Fix" +[UPDATE]:http://atlas-resources.wooga.com/icons/icon_update.svg "Update" + +[BREAK]:http://atlas-resources.wooga.com/icons/icon_break.svg "Break" +[REMOVE]:http://atlas-resources.wooga.com/icons/icon_remove.svg "Remove" +[IOS]:http://atlas-resources.wooga.com/icons/icon_iOS.svg "iOS" +[ANDROID]:http://atlas-resources.wooga.com/icons/icon_android.svg "Android" +[WEBGL]:http://atlas-resources.wooga.com/icons/icon_webGL.svg "Web:GL" + + diff --git a/build.gradle b/build.gradle new file mode 100755 index 0000000..a19e922 --- /dev/null +++ b/build.gradle @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +plugins { + id 'net.wooga.plugins' version '1.5.0' +} + +group 'net.wooga.gradle' +description = 'a secrets resolver plugin for Gradle.' + +pluginBundle { + website = 'https://wooga.github.io/atlas-secrets/' + vcsUrl = 'https://github.com/wooga/atlas-secrets' + tags = ['secrets', 'secrets-manager', 'password', 'credentials'] + + plugins { + secrets { + id = 'net.wooga.secrets' + displayName = 'Secrets' + description = 'Wooga Gradle plugin to fetch secret values' + } + } +} + +github { + repositoryName = "wooga/atlas-secrets" +} + +dependencies { + testCompile('org.spockframework:spock-core:1.2-groovy-2.4') { + exclude module: 'groovy-all' + } + + compile 'software.amazon.awssdk:secretsmanager:2.13.42' + compile "org.yaml:snakeyaml:1.26" + compile 'org.apache.commons:commons-text:1.8' + testCompile 'com.github.stefanbirkner:system-rules:1.18.0' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000000000000000000000000000000000000..e78a3c744786013426fc5b6e4f437d428f0cf528 GIT binary patch literal 54706 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 pd@g@>n*8v&{4Co5u>a_Wbzk{{hVg$?yOG literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100755 index 0000000..2081bf5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,22 @@ +# +# Copyright 2020 Wooga GmbH +# +# 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. +# + +#Tue Sep 04 16:59:21 CEST 2018 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip diff --git a/gradle_check_versions.sh b/gradle_check_versions.sh new file mode 100755 index 0000000..093ac27 --- /dev/null +++ b/gradle_check_versions.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +versions=("4.0" "4.1" "4.2" "4.3" "4.4" "4.5" "4.6" "4.7" "4.8" "4.9" "4.10") + +for i in "${versions[@]}" +do + echo "test gradle version $i" + GRADLE_VERSION=$i ./gradlew integrationTest &> /dev/null + status=$? + mkdir -p "build/reports/$i" + mv build/reports/integrationTest "build/reports/$i" + if [ $status -ne 0 ]; then + echo "test error $i" + fi +done diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4453cce --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save ( ) { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100755 index 0000000..38a608a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +/* +// To declare projects as part of a multi-project build use the 'include' method +include 'shared' +include 'api' +include 'services:webservice' +*/ + +rootProject.name = 'atlas-secrets' diff --git a/src/integrationTest/groovy/wooga/gradle/secrets/IntegrationSpec.groovy b/src/integrationTest/groovy/wooga/gradle/secrets/IntegrationSpec.groovy new file mode 100755 index 0000000..f8abbe3 --- /dev/null +++ b/src/integrationTest/groovy/wooga/gradle/secrets/IntegrationSpec.groovy @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import org.apache.commons.text.StringEscapeUtils +import org.junit.Rule +import nebula.test.functional.ExecutionResult +import org.junit.contrib.java.lang.system.EnvironmentVariables +import org.junit.contrib.java.lang.system.ProvideSystemProperty + +class IntegrationSpec extends nebula.test.IntegrationSpec { + + @Rule + ProvideSystemProperty properties = new ProvideSystemProperty("ignoreDeprecations", "true") + + @Rule + public final EnvironmentVariables environmentVariables = new EnvironmentVariables() + + def escapedPath(String path) { + String osName = System.getProperty("os.name").toLowerCase() + if (osName.contains("windows")) { + return StringEscapeUtils.escapeJava(path) + } + path + } + + def setup() { + def gradleVersion = System.getenv("GRADLE_VERSION") + if (gradleVersion) { + this.gradleVersion = gradleVersion + fork = true + } + + environmentVariables.clear( + // add env vars to clear before test. + ) + } + + Boolean outputContains(ExecutionResult result, String message) { + result.standardOutput.contains(message) || result.standardError.contains(message) + } + + String wrapValueBasedOnType(Object rawValue, String type) { + def value + def rawValueEscaped = String.isInstance(rawValue) ? "'${rawValue}'" : rawValue + def subtypeMatches = type =~ /(?\w+)<(?[\w<>]+)>/ + def subType = (subtypeMatches.matches()) ? subtypeMatches.group("subType") : null + type = (subtypeMatches.matches()) ? subtypeMatches.group("mainType") : type + switch (type) { + case "Closure": + if (subType) { + value = "{${wrapValueBasedOnType(rawValue, subType)}}" + } else { + value = "{$rawValueEscaped}" + } + break + case "Callable": + value = "new java.util.concurrent.Callable<${rawValue.class.typeName}>() {@Override ${rawValue.class.typeName} call() throws Exception { $rawValueEscaped }}" + break + case "Object": + value = "new Object() {@Override String toString() { ${rawValueEscaped}.toString() }}" + break + case "Provider": + switch (subType) { + case "RegularFile": + value = "project.layout.file(${wrapValueBasedOnType(rawValue, "Provider")})" + break + default: + value = "project.provider(${wrapValueBasedOnType(rawValue, "Closure<${subType}>")})" + break + } + break + case "String": + value = "$rawValueEscaped" + break + case "String[]": + value = "'{${rawValue.collect { '"' + it + '"' }.join(",")}}'.split(',')" + break + case "File": + value = "new File('${escapedPath(rawValue.toString())}')" + break + case "String...": + value = "${rawValue.collect { '"' + it + '"' }.join(", ")}" + break + case "List": + value = "[${rawValue.collect { '"' + it + '"' }.join(", ")}]" + break + default: + value = rawValue + } + value + } +} diff --git a/src/integrationTest/groovy/wooga/gradle/secrets/tasks/FetchSecretsTaskIntegrationSpec.groovy b/src/integrationTest/groovy/wooga/gradle/secrets/tasks/FetchSecretsTaskIntegrationSpec.groovy new file mode 100644 index 0000000..a5c2008 --- /dev/null +++ b/src/integrationTest/groovy/wooga/gradle/secrets/tasks/FetchSecretsTaskIntegrationSpec.groovy @@ -0,0 +1,282 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.tasks + +import org.yaml.snakeyaml.Yaml +import spock.lang.Unroll +import wooga.gradle.secrets.IntegrationSpec +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretsPlugin +import wooga.gradle.secrets.internal.DefaultSecret +import wooga.gradle.secrets.internal.EncryptionSpecHelper +import wooga.gradle.secrets.IntegrationSpec +import wooga.gradle.secrets.internal.Resolver + +class FetchSecretsTaskIntegrationSpec extends IntegrationSpec { + def setup() { + buildFile << """ + ${applyPlugin(SecretsPlugin)} + + import ${Resolver.name} + import ${DefaultSecret.name} + import ${SecretResolver.name} + import ${Secret.name} + + task("fetchSecretsCustom", type: ${FetchSecrets.name}) { + secretIds = [ + 'net_wooga_testCredential', + 'net_wooga_testCredential2', + 'net_wooga_testCredential3', + 'net_wooga_testCredential4' + ] + } + """.stripIndent() + } + + def "task is Up-To-Date when secret key is cached"() { + given: "a secret key" + def key = EncryptionSpecHelper.createSecretKey(this.class.name) + def keyFile = createFile("secrets.key", projectDir) + keyFile.bytes = key.encoded + + and: "the key configured" + buildFile << """ + fetchSecretsCustom.secretsKey = "${escapedPath(keyFile.path)}" + """ + + and: "a fake resolver" + buildFile << """ + fetchSecretsCustom.resolver = Resolver.withClosure { + if(it == "net_wooga_testCredential2") { + return it.toUpperCase().bytes + } else { + return it.toUpperCase() + } + } + """.stripIndent() + + and: "a future secrets file" + def secretsFile = new File(projectDir, "build/secret/fetchSecretsCustom/secrets.yml") + assert !secretsFile.exists() + + when: + def result = runTasksSuccessfully("fetchSecretsCustom") + + then: + !result.wasUpToDate("fetchSecretsCustom") + + when: + result = runTasksSuccessfully("fetchSecretsCustom") + + then: + result.wasUpToDate("fetchSecretsCustom") + secretsFile.exists() + + when: + secretsFile.delete() + result = runTasksSuccessfully("fetchSecretsCustom") + + then: + !result.wasUpToDate("fetchSecretsCustom") + } + + def "task is not Up-To-Date when resolver changes"() { + given: "a secret key" + def key = EncryptionSpecHelper.createSecretKey(this.class.name) + def keyFile = createFile("secrets.key", projectDir) + keyFile.bytes = key.encoded + + and: "the key configured" + buildFile << """ + fetchSecretsCustom.secretsKey = "${escapedPath(keyFile.path)}" + """ + + and: "a fake resolver" + buildFile << """ + class Resolver1 implements SecretResolver { + @Override + Secret resolve(String secretId) { + if(secretId == "net_wooga_testCredential2") { + return new DefaultSecret(secretId.toUpperCase().bytes) + } else { + return new DefaultSecret(secretId.toUpperCase()) + } + } + } + + class Resolver2 implements SecretResolver { + @Override + Secret resolve(String secretId) { + if(secretId == "net_wooga_testCredential3") { + return new DefaultSecret(secretId.toUpperCase().bytes) + } else { + return new DefaultSecret(secretId.toUpperCase()) + } + } + } + + fetchSecretsCustom.resolver = new Resolver1() + """.stripIndent() + + and: "a future secrets file" + def secretsFile = new File(projectDir, "build/secret/fetchSecretsCustom/secrets.yml") + assert !secretsFile.exists() + + when: "first run" + def result = runTasksSuccessfully("fetchSecretsCustom") + + then: + !result.wasUpToDate("fetchSecretsCustom") + secretsFile.exists() + + when: "second run" + result = runTasksSuccessfully("fetchSecretsCustom") + + then: + result.wasUpToDate("fetchSecretsCustom") + secretsFile.exists() + + when: "changing the resolver" + buildFile << """ + fetchSecretsCustom.resolver = new Resolver2() + """.stripIndent() + result = runTasksSuccessfully("fetchSecretsCustom") + + then: + !result.wasUpToDate("fetchSecretsCustom") + } + + + def "Second task can depend on secret output file"() { + given: "a fake resolver" + buildFile << """ + fetchSecretsCustom.resolver = Resolver.withClosure { + if(it == "net_wooga_testCredential2") { + return it.toUpperCase().bytes + } else { + return it.toUpperCase() + } + } + """.stripIndent() + + and: "a task to use the secrets" + buildFile << """ + class Consumer extends DefaultTask { + @InputFile + final RegularFileProperty inputFile = newInputFile() + + @TaskAction + void consume() { + def input = inputFile.get().asFile + def message = input.text + } + } + + task secretConsumer(type: Consumer) { + inputFile = fetchSecretsCustom.secretsFile + } + """.stripIndent() + + when: + def result = runTasksSuccessfully("secretConsumer") + + then: + result.wasExecuted("fetchSecretsCustom") + } + + + @Unroll + def "can set property #property with #method"() { + given: "a custom fetch secrets task" + buildFile << """ + task("fetchSecretsCustom2", type: ${FetchSecrets.name}) + """.stripIndent() + + and: "a task to read back the value" + buildFile << """ + task("readValue") { + doLast { + println("secretIds: " + fetchSecretsCustom2.${property}.get()) + } + } + """.stripIndent() + + and: "a set property" + buildFile << """ + fetchSecretsCustom2.${method}($value) + """.stripIndent() + + when: + def result = runTasksSuccessfully("readValue") + + then: + outputContains(result, "secretIds: " + expectedValue.toString()) + + where: + property | method | rawValue | type + "secretIds" | "secretIds" | ["Test1"] | "List" + "secretIds" | "secretId" | "Test1" | "String" + "secretIds" | "secretIds" | ["Test1", "Test2"] | "List" + "secretIds" | "secretIds" | ["Test1", "Test2"] | "String..." + "secretIds" | "setSecretIds" | ["Test1", "Test2"] | "List" + "secretIds" | "setSecretIds" | ["Test1", "Test2"] | "String..." + value = wrapValueBasedOnType(rawValue, type) + expectedValue = [rawValue].flatten() + } + + @Unroll + def "#method will #setType value"() { + given: "a custom fetch secrets task" + buildFile << """ + task("fetchSecretsCustom2", type: ${FetchSecrets.name}) { + secretIds = ['secret1'] + } + """.stripIndent() + + and: "a task to read back the value" + buildFile << """ + task("readValue") { + doLast { + println("secretIds: " + fetchSecretsCustom2.${property}.get()) + } + } + """.stripIndent() + + and: "a set property" + buildFile << """ + fetchSecretsCustom2.${method}($value) + """.stripIndent() + + when: + def result = runTasksSuccessfully("readValue") + + then: + outputContains(result, "secretIds: " + expectedValue.toString()) + + where: + property | method | rawValue | type | append | expectedValue + "secretIds" | "secretIds" | ["Test1"] | "List" | true | ['secret1', 'Test1'] + "secretIds" | "secretId" | "Test1" | "String" | true | ['secret1', 'Test1'] + "secretIds" | "secretIds" | ["Test1", "Test2"] | "List" | true | ['secret1', 'Test1', 'Test2'] + "secretIds" | "secretIds" | ["Test1", "Test2"] | "String..." | true | ['secret1', 'Test1', 'Test2'] + "secretIds" | "setSecretIds" | ["Test1", "Test2"] | "List" | false | ['Test1', 'Test2'] + "secretIds" | "setSecretIds" | ["Test1", "Test2"] | "String..." | false | ['Test1', 'Test2'] + setType = (append) ? 'append' : 'replace' + value = wrapValueBasedOnType(rawValue, type) + } +} diff --git a/src/integrationTest/groovy/wooga/gradle/secrets/tasks/SecretSpecIntegrationSpec.groovy b/src/integrationTest/groovy/wooga/gradle/secrets/tasks/SecretSpecIntegrationSpec.groovy new file mode 100644 index 0000000..66403be --- /dev/null +++ b/src/integrationTest/groovy/wooga/gradle/secrets/tasks/SecretSpecIntegrationSpec.groovy @@ -0,0 +1,103 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.tasks + +import org.gradle.api.Task +import spock.lang.Unroll + +import wooga.gradle.secrets.IntegrationSpec +import wooga.gradle.secrets.internal.DefaultSecretsPluginExtension +import wooga.gradle.secrets.internal.EncryptionSpecHelper + +class SecretSpecIntegrationSpec extends IntegrationSpec { + + @Unroll("#containerTypeName of type #containerType.name can set secrets key with #method(#type)") + def "can set secrets key"() { + given: "secret key saved to disc" + def key = EncryptionSpecHelper.createSecretKey("a random key") + def keyFile = File.createTempFile("secret", "key") + def outputKeyFile = File.createTempFile("secretOut", "key") + def keyPath = escapedPath(keyFile.path) + keyFile.bytes = key.encoded + + and: "the value to set" + def value = "" + if (type == "key") { + value = "new javax.crypto.spec.SecretKeySpec(project.file('${keyPath}').bytes, 'AES')" + } else if (type == "keyFile") { + value = "project.file('${keyPath}')" + } else if (type == "keyPath") { + value = "'${keyPath}'" + } + + and: "the key configured" + if (Task.isAssignableFrom(containerType)) { + buildFile << """ + task("temp", type: ${containerType.name}) { + ${method}(${value}) + } + """.stripIndent() + } else { + buildFile << """ + extensions.create('temp', ${containerType.name}, project) + temp.${method}(${value}) + """.stripIndent() + } + + and: "a task to write out the key" + buildFile << """ + task("writeKey") { + doLast { + def output = new File("${escapedPath(outputKeyFile.path)}") + output.bytes = temp.secretsKey.get().encoded + } + } + """ + + when: + runTasksSuccessfully("writeKey") + + then: + outputKeyFile.exists() + outputKeyFile.bytes == keyFile.bytes + + cleanup: + keyFile.delete() + outputKeyFile.delete() + + where: + containerType | property | type | useSetter + FetchSecrets | "secretsKey" | "key" | false + FetchSecrets | "secretsKey" | "key" | true + FetchSecrets | "secretsKey.set" | "key" | false + FetchSecrets | "secretsKey" | "keyFile" | false + FetchSecrets | "secretsKey" | "keyFile" | true + FetchSecrets | "secretsKey" | "keyPath" | false + FetchSecrets | "secretsKey" | "keyPath" | true + + DefaultSecretsPluginExtension | "secretsKey" | "key" | false + DefaultSecretsPluginExtension | "secretsKey" | "key" | true + DefaultSecretsPluginExtension | "secretsKey.set" | "key" | false + DefaultSecretsPluginExtension | "secretsKey" | "keyFile" | false + DefaultSecretsPluginExtension | "secretsKey" | "keyFile" | true + DefaultSecretsPluginExtension | "secretsKey" | "keyPath" | false + DefaultSecretsPluginExtension | "secretsKey" | "keyPath" | true + + method = (useSetter) ? "set${property.capitalize()}" : property + containerTypeName = Task.isAssignableFrom(containerType) ? "task" : "extension" + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/BasicAWSCredentials.groovy b/src/main/groovy/wooga/gradle/secrets/BasicAWSCredentials.groovy new file mode 100644 index 0000000..6850d6f --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/BasicAWSCredentials.groovy @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import software.amazon.awssdk.auth.credentials.AwsCredentials + +class BasicAWSCredentials implements AwsCredentials { + + final String accessKeyId + final String secretAccessKey + + BasicAWSCredentials(String AWSAccessKeyId, String AWSSecretKey) { + this.accessKeyId = AWSAccessKeyId + this.secretAccessKey = AWSSecretKey + } + + @Override + String accessKeyId() { + accessKeyId + } + + @Override + String secretAccessKey() { + secretAccessKey + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/EncryptedSecret.groovy b/src/main/groovy/wooga/gradle/secrets/EncryptedSecret.groovy new file mode 100644 index 0000000..745813e --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/EncryptedSecret.groovy @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + + +import javax.crypto.spec.SecretKeySpec + +interface EncryptedSecret extends Secret { + T decryptedSecretValue(SecretKeySpec key) + + Secret decryptSecret(SecretKeySpec key) +} diff --git a/src/main/groovy/wooga/gradle/secrets/Secret.groovy b/src/main/groovy/wooga/gradle/secrets/Secret.groovy new file mode 100644 index 0000000..d5d5416 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/Secret.groovy @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +interface Secret { + T getSecretValue() +} diff --git a/src/main/groovy/wooga/gradle/secrets/SecretResolver.groovy b/src/main/groovy/wooga/gradle/secrets/SecretResolver.groovy new file mode 100644 index 0000000..1b751a0 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/SecretResolver.groovy @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +interface SecretResolver { + Secret resolve(String secretId) +} + diff --git a/src/main/groovy/wooga/gradle/secrets/SecretResolverException.groovy b/src/main/groovy/wooga/gradle/secrets/SecretResolverException.groovy new file mode 100644 index 0000000..9fed11e --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/SecretResolverException.groovy @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import groovy.transform.InheritConstructors + +@InheritConstructors +class SecretResolverException extends Exception { + +} diff --git a/src/main/groovy/wooga/gradle/secrets/SecretSpec.groovy b/src/main/groovy/wooga/gradle/secrets/SecretSpec.groovy new file mode 100644 index 0000000..f805f50 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/SecretSpec.groovy @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import org.gradle.api.provider.Property + +import javax.crypto.spec.SecretKeySpec + +interface SecretSpec { + + Property getSecretsKey() + void setSecretsKey(SecretKeySpec key) + T setSecretsKey(String keyFile) + T setSecretsKey(File keyFile) + + T secretsKey(SecretKeySpec key) + T secretsKey(String keyFile) + T secretsKey(File keyFile) +} diff --git a/src/main/groovy/wooga/gradle/secrets/SecretsConsts.groovy b/src/main/groovy/wooga/gradle/secrets/SecretsConsts.groovy new file mode 100755 index 0000000..9f669cb --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/SecretsConsts.groovy @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +class SecretsConsts { + static Integer SECRETS_KEY_ITERATION = 65536 + static Integer SECRETS_KEY_LENGTH = 256 + + static String SECRETS_KEY_OPTION = "secrets.secretsKey" + static String SECRETS_KEY_ENV_VAR = "SECRETS_SECRETS_KEY" +} diff --git a/src/main/groovy/wooga/gradle/secrets/SecretsPlugin.groovy b/src/main/groovy/wooga/gradle/secrets/SecretsPlugin.groovy new file mode 100755 index 0000000..08e3899 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/SecretsPlugin.groovy @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.logging.Logging +import org.slf4j.Logger +import wooga.gradle.secrets.internal.DefaultSecretsPluginExtension +import wooga.gradle.secrets.tasks.FetchSecrets + +class SecretsPlugin implements Plugin { + + static Logger logger = Logging.getLogger(SecretsPlugin) + + static String EXTENSION_NAME = "secrets" + + @Override + void apply(Project project) { + def extension = create_and_configure_extension(project) + + def exampleTask = project.tasks.create("fetchSecrets", FetchSecrets) + exampleTask.group = "Secrets" + exampleTask.description = "Fetch configured secrets" + + project.tasks.withType(FetchSecrets, new Action() { + @Override + void execute(FetchSecrets t) { + t.secretsKey.set(extension.secretsKey) + t.secretsFile.set(project.provider({ + project.layout.buildDirectory.dir("secret/${t.name}").get().file("secrets.yml") + })) + t.resolver.set(extension.secretResolver) + } + }) + } + + protected static SecretsPluginExtension create_and_configure_extension(Project project) { + def extension = project.extensions.create(SecretsPluginExtension, EXTENSION_NAME, DefaultSecretsPluginExtension, project) + + extension + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/SecretsPluginExtension.groovy b/src/main/groovy/wooga/gradle/secrets/SecretsPluginExtension.groovy new file mode 100755 index 0000000..970b724 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/SecretsPluginExtension.groovy @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import org.gradle.api.provider.Property + +interface SecretsPluginExtension extends SecretSpec { + Property getSecretResolver() +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolver.groovy b/src/main/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolver.groovy new file mode 100644 index 0000000..40b5c40 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolver.groovy @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse +import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretResolverException + +class AWSSecretsManagerResolver implements SecretResolver { + + private final SecretsManagerClient secretsManager + + AWSSecretsManagerResolver(SecretsManagerClient client) { + secretsManager = client + } + + AWSSecretsManagerResolver(AwsCredentialsProvider credentials, Region region) { + this(SecretsManagerClient.builder().credentialsProvider(credentials).region(region).build()) + } + + AWSSecretsManagerResolver(Region region) { + this(SecretsManagerClient.builder().region(region).build()) + } + + @Override + Secret resolve(String secretId) { + GetSecretValueRequest request = GetSecretValueRequest.builder().secretId(secretId).build() as GetSecretValueRequest + GetSecretValueResponse response = null + Secret secret = null + try { + response = secretsManager.getSecretValue(request) + } catch (ResourceNotFoundException e) { + throw new SecretResolverException("Unable to resolve secret with id ${secretId}", e) + } + + if (response.secretString()) { + return new DefaultSecret(response.secretString()) + } + + new DefaultSecret(response.secretBinary().asByteArray()) + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/AbstractEncryptedSecret.groovy b/src/main/groovy/wooga/gradle/secrets/internal/AbstractEncryptedSecret.groovy new file mode 100644 index 0000000..8acaff5 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/AbstractEncryptedSecret.groovy @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import groovy.transform.InheritConstructors +import wooga.gradle.secrets.EncryptedSecret +import wooga.gradle.secrets.Secret + +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.AlgorithmParameters +import java.security.GeneralSecurityException + +@InheritConstructors +abstract class AbstractEncryptedSecret extends DefaultSecret implements EncryptedSecret { + AbstractEncryptedSecret(Secret secret, SecretKeySpec key) { + secretValue = encryptSecretValue(secret.secretValue, key) + } + + protected static byte[] encrypt(byte[] property, SecretKeySpec key) throws GeneralSecurityException, UnsupportedEncodingException { + Cipher pbeCipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + pbeCipher.init(Cipher.ENCRYPT_MODE, key) + AlgorithmParameters parameters = pbeCipher.getParameters() + IvParameterSpec ivParameterSpec = parameters.getParameterSpec(IvParameterSpec.class) + byte[] cryptoText = pbeCipher.doFinal(property) + byte[] iv = ivParameterSpec.getIV() + byte[] combined = new byte[iv.length + cryptoText.length] + System.arraycopy(iv, 0, combined, 0, iv.length) + System.arraycopy(cryptoText, 0, combined, iv.length, cryptoText.length) + + combined + } + + protected static String base64Encode(byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + protected static byte[] decrypt(byte[] value, SecretKeySpec key) throws GeneralSecurityException, IOException { + byte[] iv = Arrays.copyOfRange(value, 0, 16) + byte[] property = Arrays.copyOfRange(value, 16, value.length) + Cipher pbeCipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + pbeCipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)) + pbeCipher.doFinal(property) + } + + protected static byte[] base64Decode(String property) throws IOException { + return Base64.getDecoder().decode(property); + } + + @Override + Secret decryptSecret(SecretKeySpec key) { + new DefaultSecret(decryptedSecretValue(key)) + } + + abstract T decryptedSecretValue(SecretKeySpec key) + abstract protected T encryptSecretValue(T secret, SecretKeySpec key) +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/DefaultResolver.groovy b/src/main/groovy/wooga/gradle/secrets/internal/DefaultResolver.groovy new file mode 100644 index 0000000..1fc1856 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/DefaultResolver.groovy @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver + +class DefaultResolver implements SecretResolver { + final Closure resolver + + DefaultResolver(Closure resolver) { + this.resolver = resolver + } + + @Override + Secret resolve(String secretId) { + def secret = resolver.call(secretId) + if(String.isInstance(secret)) { + return new SecretText(secret as String) + } else { + return new SecretFile(secret as byte[]) + } + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/DefaultSecret.groovy b/src/main/groovy/wooga/gradle/secrets/internal/DefaultSecret.groovy new file mode 100644 index 0000000..5011276 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/DefaultSecret.groovy @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.Secret + +class DefaultSecret implements Secret { + protected T secretValue + + @Override + T getSecretValue() { + secretValue + } + + void setSecretValue(T value) { + secretValue = value + } + + DefaultSecret() { + } + + DefaultSecret(T secret) { + this.secretValue = secret + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/DefaultSecretsPluginExtension.groovy b/src/main/groovy/wooga/gradle/secrets/internal/DefaultSecretsPluginExtension.groovy new file mode 100755 index 0000000..c0419b4 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/DefaultSecretsPluginExtension.groovy @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import org.apache.commons.lang3.RandomStringUtils +import org.gradle.api.Project +import org.gradle.api.provider.Property +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretsConsts +import wooga.gradle.secrets.SecretsPluginExtension + +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom +import java.security.spec.KeySpec + +class DefaultSecretsPluginExtension implements SecretsPluginExtension { + protected final Project project + + final Property secretsKey + final Property secretResolver + + DefaultSecretsPluginExtension(Project project) { + this.project = project + secretsKey = project.objects.property(SecretKeySpec.class) + + secretsKey.set(new MemoisationProvider(project.provider({ + String keyPath = System.getenv().get(SecretsConsts.SECRETS_KEY_ENV_VAR) ?: + project.properties.get(SecretsConsts.SECRETS_KEY_OPTION, null) + + if (keyPath) { + return new SecretKeySpec(new File(keyPath).bytes, "AES") + } + + KeySpec spec = new PBEKeySpec(secretsKeyPassword().chars, secretsKeySalt(), SecretsConsts.SECRETS_KEY_ITERATION, SecretsConsts.SECRETS_KEY_LENGTH); + // AES-256 + SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + byte[] key = f.generateSecret(spec).getEncoded(); + return new SecretKeySpec(key, "AES"); + }))) + + secretResolver = project.objects.property(SecretResolver) + } + + void setSecretsKey(SecretKeySpec key) { + secretsKey.set(key) + } + + DefaultSecretsPluginExtension setSecretsKey(String keyFile) { + setSecretsKey(project.file(keyFile)) + } + + DefaultSecretsPluginExtension setSecretsKey(File keyFile) { + setSecretsKey(new SecretKeySpec(keyFile.bytes, "AES")) + } + + @Override + DefaultSecretsPluginExtension secretsKey(SecretKeySpec key) { + setSecretsKey(key) + } + + @Override + DefaultSecretsPluginExtension secretsKey(String keyFile) { + return setSecretsKey(keyFile) + } + + @Override + DefaultSecretsPluginExtension secretsKey(File keyFile) { + return setSecretsKey(keyFile) + } + + protected static String secretsKeyPassword() { + RandomStringUtils.random(20) + } + + protected static byte[] secretsKeySalt() { + SecureRandom random = new SecureRandom() + byte[] salt = new byte[16] + random.nextBytes(salt) + salt + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretFile.groovy b/src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretFile.groovy new file mode 100644 index 0000000..9ce689d --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretFile.groovy @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import groovy.transform.InheritConstructors + +import javax.crypto.spec.SecretKeySpec + +@InheritConstructors +class EncryptedSecretFile extends AbstractEncryptedSecret { + + @Override + byte[] decryptedSecretValue(SecretKeySpec key) { + decrypt(secretValue, key) + } + + @Override + protected byte[] encryptSecretValue(byte[] secret, SecretKeySpec key) { + encrypt(secret, key) + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretText.groovy b/src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretText.groovy new file mode 100644 index 0000000..e843030 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/EncryptedSecretText.groovy @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import groovy.transform.InheritConstructors + +import javax.crypto.spec.SecretKeySpec + +@InheritConstructors +class EncryptedSecretText extends AbstractEncryptedSecret { + @Override + String decryptedSecretValue(SecretKeySpec key) { + new String(decrypt(base64Decode(secretValue), key), "UTF8") + } + + @Override + protected String encryptSecretValue(String secret, SecretKeySpec key) { + base64Encode(encrypt(secret.bytes, key)) + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/EnvironmentResolver.groovy b/src/main/groovy/wooga/gradle/secrets/internal/EnvironmentResolver.groovy new file mode 100644 index 0000000..da567ea --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/EnvironmentResolver.groovy @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretResolverException + +class EnvironmentResolver implements SecretResolver { + @Override + Secret resolve(String secretId) { + String secret = System.getenv(secretId.toUpperCase()) + if(!secret) { + throw new SecretResolverException("Unable to resolve secret with id ${secretId}") + } + + def f = new File(secret) + if(f.exists()) { + return new DefaultSecret(f.bytes) + } + + new DefaultSecret(secret) + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/MemoisationProvider.groovy b/src/main/groovy/wooga/gradle/secrets/internal/MemoisationProvider.groovy new file mode 100644 index 0000000..b1c8610 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/MemoisationProvider.groovy @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import org.gradle.api.internal.provider.AbstractProvider +import org.gradle.api.provider.Provider + +class MemoisationProvider extends AbstractProvider implements Provider { + + private T inferredValue + private final Provider inner + + MemoisationProvider(Provider provider) { + this.inner = provider + } + + @Override + Class getType() { + null + } + + @Override + T getOrNull() { + if(!inferredValue) { + inferredValue = inner.getOrNull() + } + inferredValue + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/Resolver.groovy b/src/main/groovy/wooga/gradle/secrets/internal/Resolver.groovy new file mode 100644 index 0000000..08ed9ee --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/Resolver.groovy @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver + +class Resolver { + static SecretResolver withClosure(Closure resolver) { + new SecretResolver() { + @Override + Secret resolve(String secretId) { + def secret = resolver.call(secretId) + if(String.isInstance(secret)) { + return new SecretText(secret as String) as Secret + } else { + return new SecretFile(secret as byte[]) as Secret + } + } + } + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/SecretFile.groovy b/src/main/groovy/wooga/gradle/secrets/internal/SecretFile.groovy new file mode 100644 index 0000000..8d55eae --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/SecretFile.groovy @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import groovy.transform.InheritConstructors + +@InheritConstructors +class SecretFile extends DefaultSecret { +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/SecretResolverChain.groovy b/src/main/groovy/wooga/gradle/secrets/internal/SecretResolverChain.groovy new file mode 100644 index 0000000..f4530d9 --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/SecretResolverChain.groovy @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretResolverException + +class SecretResolverChain implements SecretResolver, List { + + @Delegate + private final List resolverChain + + SecretResolverChain(Iterable resolver) { + resolverChain = [] + addAll(resolver) + } + + SecretResolverChain() { + this([]) + } + + void setResolverChain(Iterable resolver) { + clear() + addAll(resolver) + } + + void setResolverChain(SecretResolver... resolver) { + setResolverChain(resolver.toList()) + } + + @Override + Secret resolve(String secretId) { + if(empty) { + throw new SecretResolverException("No secret resolvers configured.") + } + + Secret secret = null + + for(SecretResolver resolver in resolverChain) { + try { + secret = resolver.resolve(secretId) + if(secret) { + break + } + } + catch(SecretResolverException ignored) {} + } + + if(!secret) { + throw new SecretResolverException("Unable to resolve secret with id ${secretId}") + } + + secret + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/SecretText.groovy b/src/main/groovy/wooga/gradle/secrets/internal/SecretText.groovy new file mode 100644 index 0000000..c83af9a --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/SecretText.groovy @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import groovy.transform.InheritConstructors + +@InheritConstructors +class SecretText extends DefaultSecret { +} diff --git a/src/main/groovy/wooga/gradle/secrets/internal/Secrets.groovy b/src/main/groovy/wooga/gradle/secrets/internal/Secrets.groovy new file mode 100644 index 0000000..094372d --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/internal/Secrets.groovy @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import org.apache.commons.lang3.RandomStringUtils +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.TypeDescription +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.Constructor +import org.yaml.snakeyaml.introspector.BeanAccess +import org.yaml.snakeyaml.nodes.Tag +import org.yaml.snakeyaml.representer.Representer +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.EncryptedSecret + +import javax.crypto.spec.SecretKeySpec + +class Secrets implements Map> { + + @Delegate + Map> secrets = [:] + + Secrets() { + } + + static Secrets decode(String input) { + Constructor constructor = new Constructor() + constructor.addTypeDescription(new TypeDescription(Secrets.class, "!secrets")) + constructor.addTypeDescription(new TypeDescription(EncryptedSecretText.class, "!secretText")) + constructor.addTypeDescription(new TypeDescription(EncryptedSecretFile.class, "!secretFile")) + constructor.propertyUtils.beanAccess = BeanAccess.FIELD + Yaml yaml = new Yaml(constructor) + yaml.loadAs(input, Secrets.class) + } + + String encode() { + Representer representer = new Representer() + representer.propertyUtils.beanAccess = BeanAccess.FIELD + representer.defaultFlowStyle = DumperOptions.FlowStyle.BLOCK + representer.addClassTag(Secrets.class, new Tag("!secrets")) + representer.addClassTag(EncryptedSecretText.class, new Tag("!secretText")) + representer.addClassTag(EncryptedSecretFile.class, new Tag("!secretFile")) + Yaml yaml = new Yaml(representer) + + yaml.dump(this) + } + + class EnvironmentSecrets implements Map { + @Delegate + private final Map environment = [:] + + void clear() { + environment.each { _,secret -> + if (File.isInstance(secret)) { + ((File) secret).delete() + } + } + environment.clear() + } + } + + EnvironmentSecrets encodeEnvironment(SecretKeySpec secretsKey) { + EnvironmentSecrets env = new EnvironmentSecrets() + secrets.each {secretId, secret -> + def decodedSecret = secret.decryptedSecretValue(secretsKey) + if(String.isInstance(decodedSecret)) { + env.put(secretId.toUpperCase(), decodedSecret as String) + } else if(byte[].isInstance(decodedSecret)) { + File tempFile = File.createTempFile(RandomStringUtils.random(10, true, true), RandomStringUtils.random(10, true, true)) + tempFile.deleteOnExit() + tempFile.bytes = decodedSecret as byte[] + env.put(secretId.toUpperCase(), tempFile) + } else { + throw new ScriptException("Unsupported secret type ${secret.secretValue.class} of ${secretId}") + } + } + env + } + + void putSecret(String secretId, Secret secret, SecretKeySpec secretsKey) { + def encryptedSecret + + if(String.isInstance(secret.secretValue)) { + encryptedSecret = new EncryptedSecretText(secret as Secret, secretsKey) + } else if(byte[].isInstance(secret.secretValue)) { + encryptedSecret = new EncryptedSecretFile(secret as Secret, secretsKey) + } else { + throw new IllegalArgumentException("Unsupported secret type ${secret.secretValue.class} of ${secretId}") + } + secrets.put(secretId, encryptedSecret) + } + + Secret getSecret(String secretId, SecretKeySpec secretsKey) { + if(secrets.containsKey(secretId)) { + EncryptedSecret secret = secrets.get(secretId) + return secret.decryptSecret(secretsKey) + } + return null + } + + Collection> secretValues(SecretKeySpec secretsKey) { + secrets.values().collect {it.decryptSecret(secretsKey)} + } +} diff --git a/src/main/groovy/wooga/gradle/secrets/tasks/FetchSecrets.groovy b/src/main/groovy/wooga/gradle/secrets/tasks/FetchSecrets.groovy new file mode 100644 index 0000000..e3b255e --- /dev/null +++ b/src/main/groovy/wooga/gradle/secrets/tasks/FetchSecrets.groovy @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretResolverException +import wooga.gradle.secrets.SecretSpec +import wooga.gradle.secrets.internal.Resolver +import wooga.gradle.secrets.internal.Secrets + +import javax.crypto.spec.SecretKeySpec + +class FetchSecrets extends DefaultTask implements SecretSpec { + + @Input + final ListProperty secretIds + + void setSecretIds(Iterable value) { + secretIds.set(value) + } + + void setSecretIds(String... value) { + secretIds.set(value.toList()) + } + + FetchSecrets secretIds(String... value) { + secretIds.addAll(project.provider({ value.toList() })) + } + + FetchSecrets secretIds(Iterable value) { + secretIds.addAll(project.provider({ value.toList() })) + } + + FetchSecrets secretId(String value) { + secretIds.add(value) + } + + @Input + final Property secretsKey + + void setSecretsKey(SecretKeySpec key) { + secretsKey.set(key) + } + + FetchSecrets setSecretsKey(String keyFile) { + setSecretsKey(project.file(keyFile)) + } + + FetchSecrets setSecretsKey(File keyFile) { + setSecretsKey(new SecretKeySpec(keyFile.bytes, "AES")) + } + + @Override + FetchSecrets secretsKey(SecretKeySpec key) { + setSecretsKey(key) + } + + @Override + FetchSecrets secretsKey(String keyFile) { + return setSecretsKey(keyFile) + } + + @Override + FetchSecrets secretsKey(File keyFile) { + return setSecretsKey(keyFile) + } + + @Optional + @Input + protected Class getResolverType() { + if(resolver.present) { + resolver.get().class as Class + } + } + + @Internal + final Property resolver + + void setResolver(SecretResolver value) { + resolver.set(value) + } + + void resolver(SecretResolver value) { + setResolver(value) + } + + @OutputFile + final RegularFileProperty secretsFile + + FetchSecrets() { + secretIds = project.objects.listProperty(String) + secretsFile = newOutputFile() + resolver = project.objects.property(SecretResolver) + secretsKey = project.objects.property(SecretKeySpec) + } + + @TaskAction + protected void fetchSecrets() { + logger.info("Fetch secrets ${secretIds.get().join(", ")}") + Secrets secrets = new Secrets() + def resolver = resolver.getOrNull() + def key = secretsKey.get() + + if(resolver) { + for(String secretId in secretIds.get()) { + logger.info("Fetch secret: ${secretId}") + try { + Secret secret = resolver.resolve(secretId) + secrets.putSecret(secretId, secret, key) + } catch(SecretResolverException e){ + throw new ScriptException("unable to fetch secret ${secretId}", e) + } + } + } + this.secretsFile.get().asFile.text = secrets.encode() + } +} diff --git a/src/main/resources/META-INF/gradle-plugins/net.wooga.secrets.properties b/src/main/resources/META-INF/gradle-plugins/net.wooga.secrets.properties new file mode 100755 index 0000000..44e88ae --- /dev/null +++ b/src/main/resources/META-INF/gradle-plugins/net.wooga.secrets.properties @@ -0,0 +1,17 @@ +# +# Copyright 2020 Wooga GmbH +# +# 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. +# + +implementation-class=wooga.gradle.secrets.SecretsPlugin diff --git a/src/test/groovy/wooga/gradle/secrets/SecretsPluginActivationSpec.groovy b/src/test/groovy/wooga/gradle/secrets/SecretsPluginActivationSpec.groovy new file mode 100755 index 0000000..5dfa4fa --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/SecretsPluginActivationSpec.groovy @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import nebula.test.PluginProjectSpec + +class SecretsPluginActivationSpec extends PluginProjectSpec { + @Override + String getPluginName() { 'net.wooga.secrets' } +} diff --git a/src/test/groovy/wooga/gradle/secrets/SecretsPluginSpec.groovy b/src/test/groovy/wooga/gradle/secrets/SecretsPluginSpec.groovy new file mode 100755 index 0000000..09f7548 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/SecretsPluginSpec.groovy @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets + +import nebula.test.ProjectSpec +import spock.lang.Unroll +import wooga.gradle.secrets.tasks.FetchSecrets + +class SecretsPluginSpec extends ProjectSpec { + + public static final String PLUGIN_NAME = 'net.wooga.secrets' + + @Unroll("creates the task #taskName") + def 'Creates needed tasks'(String taskName, Class taskType) { + given: + assert !project.plugins.hasPlugin(PLUGIN_NAME) + assert !project.tasks.findByName(taskName) + + when: + project.plugins.apply(PLUGIN_NAME) + + then: + def task = project.tasks.findByName(taskName) + taskType.isInstance(task) + + where: + taskName | taskType + "fetchSecrets" | FetchSecrets + } + + @Unroll + def 'Creates the [#extensionName] extension with type #extensionType'() { + given: + assert !project.plugins.hasPlugin(PLUGIN_NAME) + assert !project.extensions.findByName(extensionName) + + when: + project.plugins.apply(PLUGIN_NAME) + + then: + def extension = project.extensions.findByName(extensionName) + extensionType.isInstance extension + + where: + extensionName | extensionType + 'secrets' | SecretsPluginExtension + } + +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolverSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolverSpec.groovy new file mode 100644 index 0000000..5877330 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/AWSSecretsManagerResolverSpec.groovy @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.core.SdkBytes +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.amazon.awssdk.services.secretsmanager.model.CreateSecretRequest +import software.amazon.awssdk.services.secretsmanager.model.DeleteSecretRequest +import spock.lang.Shared +import wooga.gradle.secrets.BasicAWSCredentials + +class AWSSecretsManagerResolverSpec extends SecretsResolverSpec { + + @Shared + SecretsManagerClient secretsManager + + @Shared + Region region = Region.EU_WEST_1 + + AWSSecretsManagerResolver resolver + + @Override + AWSSecretsManagerResolver getSubject() { + if(!resolver) { + resolver = new AWSSecretsManagerResolver(secretsManager) + } + resolver + } + + def setupSpec() { + def accessKey = System.getenv("ATLAS_AWS_INTEGRATION_ACCESS_KEY") + def secretKey = System.getenv("ATLAS_AWS_INTEGRATION_SECRET_KEY") + def builder = SecretsManagerClient.builder().region(region) + + if (accessKey && secretKey) { + def credentials = new BasicAWSCredentials(accessKey, secretKey) + def credentialsProvider = StaticCredentialsProvider.create(credentials) + builder.credentialsProvider(credentialsProvider) + } + secretsManager = builder.build() + } + + @Override + void createSecret(String secretId, byte[] secretValue) { + def r = CreateSecretRequest.builder() + .name(secretId) + .secretBinary(SdkBytes.fromByteArray(secretValue)) + .build() as CreateSecretRequest + secretsManager.createSecret(r) + } + + @Override + void createSecret(String secretId, String secretValue) { + def r = CreateSecretRequest.builder() + .name(secretId) + .secretString(secretValue) + .build() as CreateSecretRequest + secretsManager.createSecret(r) + } + + @Override + void deleteSecret(String secretId) { + def d = DeleteSecretRequest.builder().secretId(secretId).forceDeleteWithoutRecovery(true).build() as DeleteSecretRequest + secretsManager.deleteSecret(d) + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretFileSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretFileSpec.groovy new file mode 100644 index 0000000..6269526 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretFileSpec.groovy @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + + +import wooga.gradle.secrets.Secret + +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom + +class EncryptedSecretFileSpec extends EncryptedSecretSpec { + + @Override + byte[] getTestValue() { + SecureRandom random = new SecureRandom() + byte[] test = new byte[2048] + random.nextBytes(test) + test + } + + @Override + SecretFile createSecret(byte[] value) { + new SecretFile(value) + } + + @Override + EncryptedSecretFile createEncryptedSecret(Secret secret, SecretKeySpec secretKey) { + new EncryptedSecretFile(secret, secretKey) + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretSpec.groovy new file mode 100644 index 0000000..02f2492 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretSpec.groovy @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.EncryptedSecret +import wooga.gradle.secrets.Secret + +import javax.crypto.BadPaddingException +import javax.crypto.spec.SecretKeySpec + +abstract class EncryptedSecretSpec, S extends Secret> extends SecretSpec { + SecretKeySpec secretKey + + abstract E createEncryptedSecret(Secret secret, SecretKeySpec key) + + def setup() { + secretKey = EncryptionSpecHelper.createSecretKey("some_secret_passphrase") + } + + def "can encrypt secret"() { + given: "a secret" + def secret = createSecret(testValue) + + when: + def encrypted = createEncryptedSecret(secret, secretKey) + + then: + encrypted.secretValue != secret.secretValue + } + + def "can decrypt secret"() { + given: "an encrypted secret" + def secret = createSecret(testValue) + def encrypted = createEncryptedSecret(secret, secretKey) + + expect: + encrypted.decryptedSecretValue(secretKey) == secret.secretValue + } + + def "can used saved key"() { + given: "an encrypted secret" + def secret = createSecret(testValue) + def encrypted = createEncryptedSecret(secret, secretKey) + + and: "a key saved to disk" + def testKey = File.createTempFile("test","secretKey") + testKey.bytes = secretKey.encoded + + and: "a second key created from file" + def keyFromFile = new SecretKeySpec(testKey.bytes, "AES") + + expect: + encrypted.decryptedSecretValue(keyFromFile) == secret.secretValue + } + + def "decrypt with different key fails"() { + given: "an encrypted secret" + def secret = createSecret(testValue) + def encrypted = createEncryptedSecret(secret, secretKey) + + and: "a second encrypted value with a different key" + def secondKey = EncryptionSpecHelper.createSecretKey("some_other_secret_passphrase") + + when: + encrypted.decryptedSecretValue(secondKey) + + then: + thrown(BadPaddingException) + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretTextSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretTextSpec.groovy new file mode 100644 index 0000000..ac083d7 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/EncryptedSecretTextSpec.groovy @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import wooga.gradle.secrets.Secret + +import javax.crypto.spec.SecretKeySpec + +class EncryptedSecretTextSpec extends EncryptedSecretSpec { + + String testValue = "Secret123456789Secret" + + @Override + SecretText createSecret(String value) { + new SecretText(value) + } + + @Override + EncryptedSecretText createEncryptedSecret(Secret secret, SecretKeySpec secretKey) { + new EncryptedSecretText(secret, secretKey) + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/EncryptionSpecHelper.groovy b/src/test/groovy/wooga/gradle/secrets/internal/EncryptionSpecHelper.groovy new file mode 100644 index 0000000..ddccd47 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/EncryptionSpecHelper.groovy @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom +import java.security.spec.KeySpec + +class EncryptionSpecHelper { + static SecretKeySpec createSecretKey(String passphrase) { + SecureRandom random = new SecureRandom() + byte[] salt = new byte[16] + random.nextBytes(salt) + + KeySpec spec = new PBEKeySpec(passphrase.chars, salt, 65536, 256) + SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + byte[] key = f.generateSecret(spec).getEncoded(); + new SecretKeySpec(key, "AES"); + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/EnvironmentResolverSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/EnvironmentResolverSpec.groovy new file mode 100644 index 0000000..db845f7 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/EnvironmentResolverSpec.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import org.junit.Rule +import org.junit.contrib.java.lang.system.EnvironmentVariables + +class EnvironmentResolverSpec extends SecretsResolverSpec { + + @Rule + public final EnvironmentVariables environmentVariables = new EnvironmentVariables() + + @Override + EnvironmentResolver getSubject() { + new EnvironmentResolver() + } + + @Override + void createSecret(String secretId, byte[] secretValue) { + def f = File.createTempFile(secretId, "secret") + f.bytes = secretValue + f.deleteOnExit() + + environmentVariables.set(secretId.toUpperCase(), f.path) + } + + @Override + void createSecret(String secretId, String secretValue) { + environmentVariables.set(secretId.toUpperCase(), secretValue) + } + + @Override + void deleteSecret(String secretId) { + def v = System.getenv(secretId) + if (v && new File(v).exists()) { + new File(v).delete() + } + environmentVariables.clear(secretId.toUpperCase()) + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/SecretResolverChainSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/SecretResolverChainSpec.groovy new file mode 100644 index 0000000..a4a2b98 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/SecretResolverChainSpec.groovy @@ -0,0 +1,141 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import org.apache.commons.lang3.RandomStringUtils +import wooga.gradle.secrets.Secret +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretResolverException + +class SecretResolverChainSpec extends SecretsResolverSpec { + + SecretResolverChain resolverChain + + private class TestSecretResolver implements SecretResolver { + @Override + Secret resolve(String secretId) { + throw new SecretResolverException("Unable to resolve secret with id ${secretId}") + } + } + + def setup() { + resolverChain = new SecretResolverChain() + resolverChain.add(new TestSecretResolver()) + } + + def "returns the first non null secret it finds in chained resolvers"() { + given: "initial empty resolver chain" + resolverChain.clear() + + and: "a resolver that throws an exception" + resolverChain.add(new TestSecretResolver()) + + and: "a resolver that returns null" + def nullResolver = Mock(SecretResolver) + nullResolver.resolve(secretId) >> null + resolverChain.add(nullResolver) + + and: "a resolver that returns a value" + def valueResolver = Mock(SecretResolver) + valueResolver.resolve(secretId) >> new DefaultSecret("some secret") + resolverChain.add(valueResolver) + + and: "and a second resolver that returns a value" + valueResolver = Mock(SecretResolver) + valueResolver.resolve(secretId) >> new DefaultSecret("another secret") + resolverChain.add(valueResolver) + + expect: + resolverChain.resolve(secretId).secretValue == "some secret" + + where: + secretId = "some_secret_${RandomStringUtils.randomAlphabetic(20)}" + } + + def "fails when no resolver is configured"() { + given: "empty resolver chain" + resolverChain.clear() + + when: + resolverChain.resolve("someSecret") + + then: + def e = thrown(SecretResolverException) + e.message == "No secret resolvers configured." + } + + def "can append resolvers"() { + given: "empty resolver chain" + resolverChain.clear() + + when: + resolverChain.add(Mock(SecretResolver)) + + then: + resolverChain.size() == 1 + + when: + resolverChain.addAll([Mock(SecretResolver), Mock(SecretResolver)]) + + then: + resolverChain.size() == 3 + + when: + resolverChain.addAll(Mock(SecretResolver), Mock(SecretResolver)) + + then: + resolverChain.size() == 5 + } + + def "can set resolver list"() { + given: "empty resolver chain" + resolverChain.addAll(Mock(SecretResolver), Mock(SecretResolver)) + assert resolverChain.size() == 3 + + when: + resolverChain.resolverChain = Mock(SecretResolver) + + then: + resolverChain.size() == 1 + } + + @Override + SecretResolverChain getSubject() { + return resolverChain + } + + @Override + void createSecret(String secretId, byte[] secretValue) { + //create fake resolver for given secret + def resolver = Mock(SecretResolver) + resolver.resolve(secretId) >> new DefaultSecret(secretValue) + resolverChain.add(resolver) + } + + @Override + void createSecret(String secretId, String secretValue) { + //create fake resolver for given secret + def resolver = Mock(SecretResolver) + resolver.resolve(secretId) >> new DefaultSecret(secretValue) + resolverChain.add(resolver) + } + + @Override + void deleteSecret(String secretId) { + resolverChain.resolverChain = [] + } +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/SecretSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/SecretSpec.groovy new file mode 100644 index 0000000..43af10b --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/SecretSpec.groovy @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import spock.lang.Specification +import wooga.gradle.secrets.Secret + +abstract class SecretSpec> extends Specification { + abstract T getTestValue() + abstract S createSecret(T value) + + def "can fetch secret value"() { + given: "a secret" + def secret = createSecret(testValue) + + expect: + secret.secretValue != null + } + + +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/SecretsResolverSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/SecretsResolverSpec.groovy new file mode 100644 index 0000000..62b2ba4 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/SecretsResolverSpec.groovy @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import org.apache.commons.lang3.RandomStringUtils +import spock.lang.Specification +import spock.lang.Unroll +import wooga.gradle.secrets.SecretResolver +import wooga.gradle.secrets.SecretResolverException + +abstract class SecretsResolverSpec extends Specification { + + abstract T getSubject() + + @Unroll("can resolve secret #type") + def "can resolve secret"() { + given: "a secret text on AWS" + createSecret(secretId, secretValue) + + when: + def result = subject.resolve(secretId) + + then: + result != null + expectedType.isAssignableFrom(result.secretValue.class) + result.secretValue == secretValue + + cleanup: + deleteSecret(secretId) + + where: + secretValue | expectedType | type + "a random secret".toString() | String | "text" + "a random secret".bytes | byte[] | "file" + secretId = "wdk_unified_build_system_testSecret_${RandomStringUtils.randomAlphabetic(20)}" + } + + def "fails when secret can't be found"() { + when: + subject.resolve(secretId) + + then: + def e = thrown(SecretResolverException.class) + e.message == "Unable to resolve secret with id ${secretId}" + + where: + secretId = "wdk_unified_build_system_testSecret_${RandomStringUtils.randomAlphabetic(20)}" + } + + abstract void createSecret(String secretId, byte[] secretValue) + + abstract void createSecret(String secretId, String secretValue) + + abstract void deleteSecret(String secretId) +} diff --git a/src/test/groovy/wooga/gradle/secrets/internal/SecretsSpec.groovy b/src/test/groovy/wooga/gradle/secrets/internal/SecretsSpec.groovy new file mode 100644 index 0000000..25cb4a8 --- /dev/null +++ b/src/test/groovy/wooga/gradle/secrets/internal/SecretsSpec.groovy @@ -0,0 +1,183 @@ +/* + * Copyright 2020 Wooga GmbH + * + * 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. + */ + +package wooga.gradle.secrets.internal + +import spock.lang.Specification +import spock.lang.Unroll +import wooga.gradle.secrets.EncryptedSecret + +import javax.crypto.spec.SecretKeySpec + +class SecretsSpec extends Specification { + + SecretKeySpec key + Secrets secrets + + def setup() { + key = EncryptionSpecHelper.createSecretKey("secret_pass_phrase") + secrets = new Secrets() + } + + @Unroll + def "can put unencrypted secret<#type>"() { + when: + secrets.putSecret(secretId, new DefaultSecret(value), key) + + then: + noExceptionThrown() + secrets.containsKey(secretId) + secrets.get(secretId) != null + EncryptedSecret.isInstance(secrets.get(secretId)) + + where: + type | value | supported + "String" | "a secret" | true + "byte[]" | "a secret".bytes | true + secretId = "someId" + } + + def "can get unencrypted secret<#type>"() { + given: "a secret" + def secret = new DefaultSecret(value) + secrets.putSecret(secretId, secret, key) + + when: + def result = secrets.getSecret(secretId, key) + + then: + noExceptionThrown() + result != null + result.secretValue == secret.secretValue + result != secret + + where: + type | value | supported + "String" | "a secret" | true + "byte[]" | "a secret".bytes | true + secretId = "someId" + } + + def "can list secretId"() { + given: "a few secrets" + secretIds.each { + secrets.putSecret(it, new DefaultSecret(it.toUpperCase()), key) + } + + expect: + secrets.keySet() == secretIds.toSet() + + where: + secretIds = ["secret1", "secret2", "secret3", "secret4"] + } + + def "can list encrypted secret values"() { + given: "a few secrets" + secretIds.each { + secrets.putSecret(it, new DefaultSecret(testValue), key) + } + + expect: + secrets.values().size() == secretIds.size() + secrets.values().every { it.secretValue != testValue} + secrets.values().every { it.decryptedSecretValue(key) == testValue} + + where: + secretIds = ["secret1", "secret2", "secret3", "secret4"] + testValue = "testValue" + } + + def "can list decrypted secret values"() { + given: "a few secrets" + secretIds.each { + secrets.putSecret(it, new DefaultSecret(testValue), key) + } + + expect: + secrets.secretValues(key) != secrets.values() + secrets.secretValues(key).size() == secretIds.size() + secrets.secretValues(key).every { it.secretValue == testValue} + + where: + secretIds = ["secret1", "secret2", "secret3", "secret4"] + testValue = "testValue" + } + + @Unroll + def "add secret fails when type is not supported (#type)"() { + when: + secrets.putSecret("someId", new DefaultSecret(value), key) + then: + def e = thrown(IllegalArgumentException) + e.message.startsWith("Unsupported secret type") + + where: + type | value + "bool" | true + "int" | 123456 + "float" | 1.6 + } + + def "can dump encrypted secrets to yml"() { + given: "some secret texts" + secrets.putSecret("test1", new SecretText("testValue1"), key) + secrets.putSecret("test2", new SecretText("testValue2"), key) + + and: "some secret files" + secrets.putSecret("test3", new SecretFile("testValue1".bytes), key) + secrets.putSecret("test4", new SecretFile("testValue2".bytes), key) + + secrets.values() + + when: + String ymlDump = secrets.encode() + + then: + noExceptionThrown() + ymlDump != null + def loadedSecrets = Secrets.decode(ymlDump) + loadedSecrets.encode() == ymlDump + loadedSecrets.getSecret("test3", key).secretValue == "testValue1".bytes + } + + def "can encode for environment"() { + given: "some secret texts" + secrets.putSecret("test1", new SecretText("testValue1"), key) + secrets.putSecret("test2", new SecretText("testValue2"), key) + + and: "some secret files" + secrets.putSecret("test3", new SecretFile("testValue1".bytes), key) + secrets.putSecret("test4", new SecretFile("testValue2".bytes), key) + + when: + def env = secrets.encodeEnvironment(key) + + then: + noExceptionThrown() + env != null + env["TEST1"] == "testValue1" + env["TEST2"] == "testValue2" + ((File)(env["TEST3"])).bytes == "testValue1".bytes + ((File)(env["TEST4"])).bytes == "testValue2".bytes + + cleanup: + env.each { _, value -> + if (File.isInstance(value)) { + ((File) value).delete() + } + } + } +}