Published on

Extracting Jenkins Credentials for Use in Another Place

Author

I support a bunch of Jenkins servers for CI/CD. One of the things we wanted to do was stuff all our credentials into Jenkins so devs could manage them there instead of giving them rights in the AWS console to set secrets in SSM parameter store.

On its face, this might sound kinda crazy: the parameter store UI is really nice. But we decided that since we already have to set up role-based privleges for the Jenkins Folders (for managing jobs) that it’s easy to re-use this for controlling access to the secrets.

The approach taken is Terraform creates some SSM parameters with dummy values. These have a TF lifecycle rule set so it won’t recreate the param with the dummy value once it’s set. The terraform module ends up emitting a map of parameters as an output. terraform output can give you the map as JSON.

I wanted to build a reusable Jenkins step that any developer could include in their pipeline for syncronizing Jenkins credential values to the parameters in SSM. I figured I could make something happen in a script block with plain-old Groovy loop once I parsed the TF output.

I ran into tons of roadblocks:

  • Parsing JSON in a script { } block has a pitfall: its JSOn lib can return a LazyMap. Every step in a script block needs to be serializable so Jenkins can pause at any point, and that kind of map wasn’t compatible with Jenkins’ var serilization.
    • There is a workaround: you can create a function and annotate it NonCPS. This tells Jenkins it needs to execute the entire function atomically, instead of saving state after every line.
  • You normally call a helper method in withCredentials, like string(credentialsId: 'something'). Those aren’t available in a script block.
    • The workaround here is to use an array with $class: 'StringBinding' in it. Some magic happens that is equivilent to calling a helper method.

The last caveat was the hardest to get around: the withCredentials() directive is not designed to extract a variable number of credentials. In fact, I couldn’t get it to work at all inside the script, since it returned something unserializable.

I got it working with a gnarly solution. The script block parses the parameter output from TF, loops through, and builds two lists: one for withCredentials() in a normal steps block, and another injected as an environment variable for Bash to loop through.

With that done, I just needed a little shell script for parsing the env var, looping through, and calling aws ssm put-parameter.

Here’s a demo of the pipeline:

@NonCPS
def parseJson(jsonString) {
    def lazyMap = new groovy.json.JsonSlurper().parseText(jsonString)
     
    // JsonSlurper returns a non-serializable LazyMap, so copy it into a regular map before returning
    def m = [:]
    m.putAll(lazyMap)
    return m
}
 
pipeline {
    agent any
 
    stages {
        stage ('Terraform') {
          // terraform init && terraform apply -auto-approve && etc . . .
        }
 
        stage ('Publish Secrets to SSM') {
            steps {
                withCredentials([[
                    $class: 'AmazonWebServicesCredentialsBinding',
                    credentialsId: 'aws',
                    accessKeyVariable: 'AWS_ACCESS_KEY_ID',
                    secretKeyVariable: 'AWS_SECRET_ACCESS_KEY'
                ]]) {
                    script {
                        def params_json = sh(label: 'Param map', returnStdout: true, script: 'terraform output -json parameters').trim()
                        def params = parseJson(params_json)
 
                        credentialsToResolve = []
                        def CREDS_FOR_BASH = ""
                        def bashIndex = 0;
 
                        for (param in params) {
                            credentialsToResolve << [$class: 'StringBinding', credentialsId: param.key, variable: "SECRET_VALUE_${bashIndex}"]
                            CREDS_FOR_BASH = CREDS_FOR_BASH + "${param.key}\t${param.value}\t${bashIndex}\n"
                            bashIndex++;
                        }
 
                        env.CREDS_FOR_BASH = CREDS_FOR_BASH;
                    }
 
                    withCredentials(credentialsToResolve) {
                        sh '''
                        IFS='\n'
                        for line in $CREDS_FOR_BASH; do
                            param_name=$(echo $line | awk -F'\t' '{print $1}')
                            param_arn=$(echo $line | awk -F'\t' '{print $2}')
                            secret_index=$(echo $line | awk -F'\t' '{print $3}')
                            secret_var="SECRET_VALUE_${secret_index}"
                            aws ssm put-parameter --name ${param_arn} --type "SecureString" --value ${!secret_var} --region us-east-2 --overwrite
                        done;
                        '''
                    }
                }
            }
        }
    }
}

The final version got refactored into a Jenkins shared library. My developers just have to include two lines in their pipelines to get that functionality.