Support JSON formatted secrets in SecretSourceResolver (#2018)

Co-authored-by: Joseph Petersen <josephp90@gmail.com>
This commit is contained in:
Chris Kilding 2022-07-11 11:04:33 +01:00 committed by GitHub
parent befdf40784
commit 2d41195020
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 169 additions and 12 deletions

View File

@ -50,18 +50,94 @@ For example, `Jenkins: "^${some_var}"` will produce the literal `Jenkins: "${som
=== Additional variable substitution
Currently we have `base64`, `readFile`, `decodeBase64` and `readFileBase64` all serving a different purpose.
`decodeBase64` and `readFileBase64` are useful for credential plugin or any other plugin that needs binary base64 encoding of a file, in this specific case that would be useful for link:https://tools.ietf.org/html/rfc7292[a PKCS#12 certificate file].
If you need to read a secret from a file, or perform additional en/decoding of the secret prior to use, the following helpers are available:
- `${base64:HELLO WORLD}` into `SEVMTE8gV09STEQ=`
- `${readFile:/secret/file.txt}` into the content of a file.
- `${base64:${readFile:/secret/file.txt}` into a base64 representation of the content in a file
- `${base64:${readFile:${SECRET_FILE_PATH}}` nest it all together with regular secret expansion.
- `${decodeBase64:${ENV_VAR}}` decodes environment variables encoded as base64.
It might be used for passing multi-line variables like SSH keys through the environment.
- `${readFileBase64:/secret/certificate.p12}` into a base64 representation of the binary file.
- `${readFile:/secret/file.txt}` an alias for readFile
- `${fileBase64:/secret/certificate.p12}` an alias for readFileBase64
- `base64`
- `readFile`
- `decodeBase64`
- `readFileBase64`
- `json`
Helpers can be combined to do multiple transformations. Examples:
- `${base64:${readFile:/secret/file.txt}}`: read the file -> base64-encode it
- `${base64:${readFile:${SECRET_FILE_PATH}}}`: expand the SECRET_FILE_PATH variable -> read the file -> base64-encode it
==== base64
Encodes the provided secret to base64.
Example:
`${base64:HELLO WORLD}` -> `SEVMTE8gV09STEQ=`
==== decodeBase64
Decodes the provided base64-encoded secret into a plain text string.
Example:
```
${decodeBase64:${ENV_VAR}}
```
`decodeBase64` (and its related `readFileBase64`) may be useful for the credential plugin or any other plugin that needs binary base64 encoding of a file. This might help when handling link:https://tools.ietf.org/html/rfc7292[a PKCS#12 certificate files] or passing an SSH key as an environment variable.
==== file
Alias for `readFile`.
Example:
```
${file:/secret/file.txt}
```
==== fileBase64
Alias for `readFileBase64`.
Example:
```
${fileBase64:/secret/certificate.p12}
```
==== readFile
Reads the secret from the specified file.
Example:
```
${readFile:/secret/file.txt}
```
==== readFileBase64
Read the specified binary file into a base64 representation.
```
${readFileBase64:/secret/certificate.p12}
```
==== json
Parse the string secret as JSON, then extract the value for the specified `<key>`.
(An empty string is returned if the JSON secret does not contain `<key>`).
```
${json:<key>:${ENV_VAR}}
```
This can help in situations where the backend has the option to store JSON secrets, e.g. AWS Secrets Manager.
Example:
```
${json:username:${ENV_VAR}}
```
=== Security and compatibility considerations
@ -91,7 +167,7 @@ It led to a security vulnerability which was addressed in JCasC `1.25` (SECURITY
In JCasC there is a link:https://jenkins.io/doc/developer/extensions/configuration-as-code/#secretsource[SecretSource extension point] which allows resolving variables passed to JCasC.
We can provide these initial secrets in the following ways:
- link:https://github.com/jenkinsci/aws-secrets-manager-credentials-provider-plugin#secretsource[AWS Secrets Manager]
- link:https://github.com/jenkinsci/aws-secrets-manager-secret-source-plugin[AWS Secrets Manager]
- link:https://github.com/jenkinsci/configuration-as-code-secret-ssm-plugin[AWS Systems Manager Parameter Store]
- link:https://github.com/jenkinsci/azure-keyvault-plugin#secretsource[Azure KeyVault]
- Docker Secrets

View File

@ -18,6 +18,7 @@ import org.apache.commons.lang.StringUtils;
import org.apache.commons.text.StringSubstitutor;
import org.apache.commons.text.TextStringBuilder;
import org.apache.commons.text.lookup.StringLookup;
import org.json.JSONObject;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.DoNotUse;
@ -46,6 +47,7 @@ public class SecretSourceResolver {
map.put("file", FileStringLookup.INSTANCE);
map.put("readFile", FileStringLookup.INSTANCE);
map.put("decodeBase64", DecodeBase64Lookup.INSTANCE);
map.put("json", JsonLookup.INSTANCE);
map = Collections.unmodifiableMap(map);
substitutor = new StringSubstitutor(
@ -199,4 +201,29 @@ public class SecretSourceResolver {
}
}
}
static class JsonLookup implements StringLookup {
static final JsonLookup INSTANCE = new JsonLookup();
private JsonLookup() {
}
@Override
public String lookup(@NonNull final String key) {
final String[] components = key.split(":", 2);
final String jsonFieldName = components[0];
final String json = components[1];
final JSONObject root = new JSONObject(json);
final String output = root.optString(jsonFieldName, null);
if (output == null) {
LOGGER.log(Level.WARNING, String.format("Configuration import: JSON secret did not contain the specified key '%s'. Will default to empty string.", jsonFieldName));
return "";
}
return output;
}
}
}

View File

@ -348,6 +348,60 @@ public class SecretSourceResolverTest {
assertThat(actualBytes, equalTo(expectedBytes));
}
@Test
public void resolve_Json() {
String input = "{ \"a\": 1, \"b\": 2 }";
environment.set("FOO", input);
String output = resolve("${json:a:${FOO}}");
assertThat(output, equalTo("1"));
}
/**
* Check that prettified / multi-line JSON is supported
*/
@Test
public void resolve_JsonWithNewlineBetweenTokens() {
String input = "{ \n \"a\": 1, \n \"b\": 2 }";
environment.set("FOO", input);
String output = resolve("${json:a:${FOO}}");
assertThat(output, equalTo("1"));
}
@Test
public void resolve_JsonWithNewlineInValue() {
String input = "{ \"a\": \"hello\\nworld\", \"b\": 2 }";
environment.set("FOO", input);
String output = resolve("${json:a:${FOO}}");
assertThat(output, equalTo("hello\nworld"));
}
@Test
public void resolve_JsonWithSpaceInKey() {
String input = "{ \"abc def\": 1, \"b\": 2 }";
environment.set("FOO", input);
String output = resolve("${json:abc def:${FOO}}");
assertThat(output, equalTo("1"));
}
/**
* Test a mix of JSON and other lookups
*/
@Test
public void resolve_JsonFromFile() throws Exception {
String input = getPath("secret.json").toAbsolutePath().toString();
String output = resolve("${json:Our secret:${file:" + input + "}}");
assertThat(output, equalTo("Hello World"));
}
@Test
public void resolve_JsonMissingKey() {
String input ="{ \"b\": 2 }";
environment.set("FOO", input);
String output = resolve("${json:a:${FOO}}");
assertTrue(logContains("Configuration import: JSON secret did not contain the specified key 'a'. Will default to empty string."));
assertThat(output, equalTo(""));
}
@Test
@Issue("SECURITY-1446")
@WithoutJenkins