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 0000000..e78a3c7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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() + } + } + } +}