Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for specifying secrets using a list of strings. #32

Merged
merged 4 commits into from
Sep 16, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ plugins:
```
provider:
environmentSecrets:
MYSECRET: /path/to/ssm/secret
- MY_SECRET
```

The plugin will create a json file with all the secrets. In the above example the ciphertext and ARN of the secret located at `path/to/ssm/secret` will be stored in the file under the key `MYSECRET`.
The plugin will create a json file with all the secrets. In the above example the ciphertext and ARN of the secret located at `MY_SECRET` will be stored in the file under the key `MY_SECRET`.

See example code in [examples](/examples) folder for reference.

6. Ensure your Lambda has permission to decrypt the secret at runtime using the CMK. Example:
Expand All @@ -60,6 +61,39 @@ iamRoleStatements:
- [Python Example](/examples/handler.py)
- [Node Example](/examples/handler.js)

## Advanced Configuration

If you would like to name your secrets something different than the path in Parameter Store you can specify a name and path in the configuration like so:

```
provider:
environmentSecrets:
MY_SECRET: /path/to/ssm/secret
```



Or you can use a more explicit object syntax

```
provider:
environmentSecrets:
- name: CUSTOM_SECRET
path: a/custom/secret/path
```

This allows you to mix styles

```
provider:
environmentSecrets:
- MY_SECRET
- MY_OTHER_SECRET
- name: CUSTOM_SECRET
path: a/custom/secret/path
```


## Why use this plugin?

There are many solutions for secret management with AWS Lambda. Unfortunately, a lot of the solutions unnecessarily expose the secrets in plain text, incur latency by invoking an API call to decrypt a secret with _every_ lambda invocation, or require potentially complex cache invalidation for when a secret is rotated.
Expand Down
14 changes: 11 additions & 3 deletions examples/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ async function decryptSecret(secretName) {
}

module.exports.hello = async (event, context) => {
const secrets = [
"MY_SECRET",
"MY_OTHER_SECRET",
"CUSTOM_SECRET"
];
let output = "";
try {
const secret = await decryptSecret("MYSECRET");

return `Secret MYSECRET: ${secret}`;
for (const secret of secrets) {
const value = await decryptSecret(secret);
output = output + `Secret ${secret}: ${value}\n`;
}
} catch (error) {
return `ERROR!: ${error}`;
}
return output;
};
16 changes: 11 additions & 5 deletions examples/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ def kms_decrypt(secretName):
resp = client.decrypt(CiphertextBlob=b64decode(secrets[secretName]["ciphertext"]), EncryptionContext=context)
return resp['Plaintext'].decode('UTF-8')

SECRETS = [
"MY_SECRET",
"MY_OTHER_SECRET",
"CUSTOM_SECRET",
]

def hello(event, context):
global warm_secret
if not warm_secret:
warm_secret = kms_decrypt("MYSECRET")
print(warm_secret)
return "OK"
output_builder = []
for secret in SECRETS:
value = kms_decrypt(secret)
output_builder.append("%s : %s\n" % (secret, value))
return "".join(output_builder)
5 changes: 4 additions & 1 deletion examples/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ provider:
runtime: nodejs8.10
region: us-west-2
environmentSecrets:
MYSECRET: test123
- MY_SECRET
- MY_OTHER_SECRET
- name: CUSTOM_SECRET
path: /a/custom/secret/path
iamRoleStatements:
- Effect: Allow
Action:
Expand Down
32 changes: 29 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,38 @@ class ServerlessSecretBaker {
this.serverless = serverless;
}

getSecretsConfig() {
const environmentSecrets = this.serverless.service.provider.environmentSecrets || [];

if (Array.isArray(environmentSecrets)) {
return environmentSecrets.map((item) => {
if (typeof item === 'string') {
return {
name: item,
path: item
}
} else {
return item
}
})
} else if (typeof environmentSecrets === 'object') {
return Object.entries(environmentSecrets).map(([name, path]) => ({
name,
path
}));
}
throw new this.serverless.classes.Error(
"`environmentSecrets` contained an unexpected value."
);
}

async writeEnvironmentSecretToFile() {
const providerSecrets = this.serverless.service.provider.environmentSecrets || {};
const providerSecrets = this.getSecretsConfig()
const secrets = {};

for (const name of Object.keys(providerSecrets)) {
const param = await this.getParameterFromSsm(providerSecrets[name]);

for (const {name, path} of providerSecrets) {
const param = await this.getParameterFromSsm(path);

if (!param) {
throw Error(`Unable to load Secret ${name}`);
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "serverless-secret-baker",
"version": "1.0.3",
"version": "1.1.0",
"description": "Serverless Plugin to store encrypted secrets from SSM as a packaged file availble to your lambdas.",
"main": "index.js",
"scripts": {
Expand Down
128 changes: 127 additions & 1 deletion tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,22 @@ describe("ServerlessSecretBaker", () => {
});
});

describe("With Secrets", () => {
describe("With secrets in unexpected format", () => {
let serverless;
let bakedGoods;

beforeEach(() => {
serverless = makeServerless();
serverless.service.provider.environmentSecrets = 5;
bakedGoods = new ServerlessSecretBaker(serverless);
});

it('should write an empty json object to the output file.', async () => {
expect(bakedGoods.writeEnvironmentSecretToFile()).to.be.rejected;
});
})

describe("With Secrets Object", () => {
const expectedSecretName = "MY_SECRET";
const expectedParameterStoreKey = "PARAMETER STORE KEY";
const expectedCiphertext = "SECRET VALUE CIPHERTEXT";
Expand Down Expand Up @@ -139,6 +154,117 @@ describe("ServerlessSecretBaker", () => {
});
});

describe("With Secrets String Array", () => {
const expectedSecretName = "MY_SECRET";
const expectedCiphertext = "SECRET VALUE CIPHERTEXT";
const expectedArn = "SECRET VALUE CIPHERTEXT";

let serverless;
let bakedGoods;

beforeEach(() => {
serverless = makeServerless();
serverless.service.provider.environmentSecrets = [
expectedSecretName
];
bakedGoods = new ServerlessSecretBaker(serverless);
sinon.stub(bakedGoods, "getParameterFromSsm");
bakedGoods.getParameterFromSsm.resolves({
Value: expectedCiphertext,
ARN: expectedArn
});
});

it("should write ciphertext for secret to secrets file on package", async () => {
await bakedGoods.writeEnvironmentSecretToFile();
const secretsJson = fs.writeFileAsync.firstCall.args[1];
const secrets = JSON.parse(secretsJson);

expect(secrets[expectedSecretName].ciphertext).to.equal(
expectedCiphertext
);
});

it("should write ARN from secret to secrets file on package", async () => {
await bakedGoods.writeEnvironmentSecretToFile();
const secretsJson = fs.writeFileAsync.firstCall.args[1];
const secrets = JSON.parse(secretsJson);

expect(secrets[expectedSecretName].arn).to.equal(expectedArn);
});

it("should throw an error if the parameter cannot be retrieved", async () => {
bakedGoods.getParameterFromSsm.reset();
bakedGoods.getParameterFromSsm.resolves(undefined);
expect(bakedGoods.writeEnvironmentSecretToFile()).to.be.rejected;
});

it("should call getParameterFromSsm with the correct parameter key", async () => {
await bakedGoods.writeEnvironmentSecretToFile();
expect(bakedGoods.getParameterFromSsm).to.have.been.calledWith(
expectedSecretName
);
});
});

describe("With Secrets Object Array", () => {
const expectedSecretName = "MY_SECRET";
const expectedParameterStoreKey = "MY_PARAMETER_STORE_KEY"
const expectedCiphertext = "SECRET VALUE CIPHERTEXT";
const expectedArn = "SECRET VALUE CIPHERTEXT";

let serverless;
let bakedGoods;

beforeEach(() => {
serverless = makeServerless();
serverless.service.provider.environmentSecrets = [
{
name: expectedSecretName,
path: expectedParameterStoreKey
}

];
bakedGoods = new ServerlessSecretBaker(serverless);
sinon.stub(bakedGoods, "getParameterFromSsm");
bakedGoods.getParameterFromSsm.resolves({
Value: expectedCiphertext,
ARN: expectedArn
});
});

it("should write ciphertext for secret to secrets file on package", async () => {
await bakedGoods.writeEnvironmentSecretToFile();
const secretsJson = fs.writeFileAsync.firstCall.args[1];
const secrets = JSON.parse(secretsJson);

expect(secrets[expectedSecretName].ciphertext).to.equal(
expectedCiphertext
);
});

it("should write ARN from secret to secrets file on package", async () => {
await bakedGoods.writeEnvironmentSecretToFile();
const secretsJson = fs.writeFileAsync.firstCall.args[1];
const secrets = JSON.parse(secretsJson);

expect(secrets[expectedSecretName].arn).to.equal(expectedArn);
});

it("should throw an error if the parameter cannot be retrieved", async () => {
bakedGoods.getParameterFromSsm.reset();
bakedGoods.getParameterFromSsm.resolves(undefined);
expect(bakedGoods.writeEnvironmentSecretToFile()).to.be.rejected;
});

it("should call getParameterFromSsm with the correct parameter key", async () => {
await bakedGoods.writeEnvironmentSecretToFile();
expect(bakedGoods.getParameterFromSsm).to.have.been.calledWith(
expectedParameterStoreKey
);
});
});

describe("getParameterFromSsm", () => {
let bakedGoods;
let getProviderStub;
Expand Down