From 0539c2578531e0af97c3c65fedb69fa4faa891c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Aguilar=20Y=C3=A9pez?= Date: Mon, 23 Sep 2024 11:31:20 -0600 Subject: [PATCH] feat: add delete secret action (#12) --- .gitignore | 1 + README.md | 50 +++ action.yml | 3 + index.js | 1 + src/action/Action.js | 61 +++- src/action/ChangeSet.js | 29 +- src/secrets-manager/SecretsManager.js | 17 +- src/utils.js | 36 +-- tests/action/ChangeSet.test.js | 324 +++++++++++-------- tests/secrets-manager/SecretsManager.test.js | 36 +++ 10 files changed, 362 insertions(+), 196 deletions(-) diff --git a/.gitignore b/.gitignore index c6bba59..82e393d 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,7 @@ dist # Stores VSCode versions used for testing VSCode extensions .vscode-test +.idea/ # yarn v2 .yarn/cache diff --git a/README.md b/README.md index 8782218..efaa5e1 100644 --- a/README.md +++ b/README.md @@ -34,4 +34,54 @@ jobs: dry_run: true # Default false show_values: false # If true secret values will be displayed on action logs (default false) exclude: "^_" # Regular expression that excludes the matching keys to be synced (default '^_') + delete_secret: false # If true it will delete the secret instead of creating or updating its values. +``` + +### Syncing secrets + +This should be the configuration to sync the secret values from a json file to the AWS Secrets Manager. + +```yml +jobs: + sync-secrets: + name: Sync secrets + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync + uses: Drafteame/sync-secrets-manager@main + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_region: us-east-1 + secret_name: my-secret + json_file_path: path/to/json/secrets.json + + # Optional if secret does not exist + create_secret: true +``` + +### Deleting secrets + +This should be the configuration to delete the secret from the AWS Secrets Manager. + +```yml +jobs: + sync-secrets: + name: Sync secrets + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync + uses: Drafteame/sync-secrets-manager@main + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_region: us-east-1 + secret_name: my-secret + delete_secret: true ``` diff --git a/action.yml b/action.yml index 0a7dce9..274fe8b 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,9 @@ inputs: exclude: description: "List of regular expressions that determines if a secret key should be excluded from sync" required: false + delete_secret: + description: "Flag that marks the specified secret to be deleted" + required: false runs: using: "docker" diff --git a/index.js b/index.js index 48ec95e..16c7171 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,7 @@ const getAction = () => { getInput("exclude"), getBooleanInput("show_values", false), getBooleanInput("create_secret", false), + getBooleanInput("delete_secret", false), ); }; diff --git a/src/action/Action.js b/src/action/Action.js index 88cd3ab..88b85da 100644 --- a/src/action/Action.js +++ b/src/action/Action.js @@ -41,6 +41,11 @@ export default class Action { */ #createSecretFlag; + /** + * Flag that marks the specified secret to be deleted + */ + #deleteSecretFlag; + /** * Creates a new Action instance. * @@ -49,11 +54,12 @@ export default class Action { * @param {string} region The AWS region. * @param {string} secretName The name of the secret in AWS Secrets Manager. * @param {string} jsonFile The path to the JSON file containing the new secret values. - * @param {string} skipPattern A regular expression that eval keys of the json file and if match, + * @param {string} skipPattern A regular expression that eval keys of the json file and if matched, * that key should be omitted * @param {boolean} showValues If this flag is set to true all secret values will be displayed on logs, - * if false, a place holder will be displayed. - * @param {boolean} createSecretFlag Flag to create the secret before sync if not exists + * if false, a placeholder will be displayed. + * @param {boolean} createSecret Flag to create the secret before sync if not exists + * @param {boolean} deleteSecret Flag that marks the specified secret to be deleted. * * @throws {Error} Throws an error if any required parameter is missing or if the JSON file doesn't exist. */ @@ -66,13 +72,22 @@ export default class Action { skipPattern, showValues = false, createSecret = false, + deleteSecret = false, ) { - this.#validateData(keyId, secretKey, region, secretName, jsonFile); + this.#validateData( + keyId, + secretKey, + region, + secretName, + jsonFile, + deleteSecret, + ); this.#jsonFile = jsonFile; this.#skipPattern = skipPattern || defaultSkipPattern; this.#showValues = showValues; this.#createSecretFlag = createSecret; + this.#deleteSecretFlag = deleteSecret; this.#smClient = new SecretsManager(keyId, secretKey, region, secretName); } @@ -95,6 +110,15 @@ export default class Action { this.#createSecretFlag = flag; } + /** + * Set the deleteSecretFlag after constructor. + * + * @param {boolean} flag - Value of the flag + */ + setDeleteSecretFlag(flag) { + this.#deleteSecretFlag = flag; + } + /** * Runs the action to synchronize secrets by fetching existing secrets and creating a change set. * @@ -103,8 +127,13 @@ export default class Action { async run() { await this.#createSecret(); - const existingSecretData = await this.#smClient.getValues(); - const newSecretData = JSON.parse(fs.readFileSync(this.#jsonFile, "utf8")); + let existingSecretData = {}; + let newSecretData = {}; + + if (!this.#deleteSecretFlag) { + existingSecretData = await this.#smClient.getValues(); + newSecretData = JSON.parse(fs.readFileSync(this.#jsonFile, "utf8")); + } return new ChangeSet( this.#smClient, @@ -112,6 +141,7 @@ export default class Action { existingSecretData, this.#skipPattern, this.#showValues, + this.#deleteSecretFlag, ); } @@ -119,7 +149,7 @@ export default class Action { * Execute secret creation if needed */ async #createSecret() { - if (!this.#createSecretFlag) { + if (this.#deleteSecretFlag || !this.#createSecretFlag) { core.info("secret creation skip..."); return; } @@ -139,10 +169,11 @@ export default class Action { * @param {string} region - The AWS region. * @param {string} secretName - The name of the secret in AWS Secrets Manager. * @param {string} jsonFile - The path to the JSON file containing the new secret values. + * @param {boolean} deleteSecret - Flag to validate if the secret should be deleted. * * @throws {Error} Throws an error if any required parameter is missing or if the JSON file doesn't exist. */ - #validateData(keyId, secretKey, region, secretName, jsonFile) { + #validateData(keyId, secretKey, region, secretName, jsonFile, deleteSecret) { if (!keyId) { throw new Error("Missing aws_access_key_id"); } @@ -159,13 +190,15 @@ export default class Action { throw new Error("Missing secret_name"); } - if (!jsonFile) { - throw new Error("Missing json_file_path"); - } + if (!deleteSecret) { + if (!jsonFile) { + throw new Error("Missing json_file_path"); + } - // Check if the JSON file exists - if (!fs.existsSync(jsonFile)) { - throw new Error(`JSON file does not exist at path: ${jsonFile}`); + // Check if the JSON file exists + if (!fs.existsSync(jsonFile)) { + throw new Error(`JSON file does not exist at path: ${jsonFile}`); + } } } } diff --git a/src/action/ChangeSet.js b/src/action/ChangeSet.js index f76c0ff..cfe1041 100644 --- a/src/action/ChangeSet.js +++ b/src/action/ChangeSet.js @@ -40,11 +40,17 @@ export default class ChangeSet { #skipPattern; /** - * A flag to unhide secret values on log messages + * A flag to un-hide secret values on log messages * @type {boolean} */ #showValues; + /** + * A flag to delete the secret + * @type {boolean} + */ + #deleteAction; + /** * Creates a new ChangeSet instance. * @@ -52,7 +58,8 @@ export default class ChangeSet { * @param {Object} newValues The new set of values to be applied to the secrets manager. * @param {Object} existingValues The existing set of values to be replaced by the new ones. * @param {string} skipPattern A regular expression to skip keys. - * @param {boolean} showValues A flag to unhide secret values on log messages + * @param {boolean} showValues A flag to un-hide secret values on log messages + * @param {boolean} deleteAction A flag to delete the secret */ constructor( smClient, @@ -60,12 +67,14 @@ export default class ChangeSet { existingValues, skipPattern, showValues = false, + deleteAction = false, ) { this.#changeDesc = []; this.#updatedValues = { ...existingValues }; this.#smClient = smClient; this.#skipPattern = skipPattern || ""; this.#showValues = showValues; + this.#deleteAction = deleteAction; this.#eval(newValues, existingValues); } @@ -85,6 +94,11 @@ export default class ChangeSet { * @returns {Promise} A promise that resolves when the update is completed. */ async apply() { + if (this.#deleteAction) { + await this.#smClient.delete(); + return; + } + await this.#smClient.update(this.#updatedValues); } @@ -95,6 +109,11 @@ export default class ChangeSet { * @param {Object} existingValues - The existing set of values to be replaced by the new ones. */ #eval(newValues, existingValues) { + if (this.#deleteAction) { + this.#removedDesc("ALL_KEYS"); + return; + } + // Check for changes and update the secret (or preview changes) for (const key in newValues) { if (this.#shouldSkip(key)) { @@ -141,11 +160,7 @@ export default class ChangeSet { let exp = new RegExp(this.#skipPattern); - if (exp.test(key)) { - return true; - } - - return false; + return exp.test(key); } /** diff --git a/src/secrets-manager/SecretsManager.js b/src/secrets-manager/SecretsManager.js index 7457324..2f1da03 100644 --- a/src/secrets-manager/SecretsManager.js +++ b/src/secrets-manager/SecretsManager.js @@ -4,6 +4,7 @@ import { UpdateSecretCommand, CreateSecretCommand, ListSecretsCommand, + DeleteSecretCommand, } from "@aws-sdk/client-secrets-manager"; import lodash from "lodash"; @@ -39,7 +40,7 @@ export default class SecretsManager { } /** - * Take a new set of values an replace the current values for the configured secrets manager. + * Take a new set of values as replacement the current values for the configured secrets manager. * * @param {Object} newValues Object with new values to replace existing ones on secrets manager */ @@ -74,7 +75,7 @@ export default class SecretsManager { const res = await this.client.send(listCommand); - if (res.SecretList.length == 0) { + if (res.SecretList.length === 0) { return false; } @@ -100,4 +101,16 @@ export default class SecretsManager { await this.client.send(createCommand); } + + /** + * Delete the given secret name + */ + async delete() { + const deleteCommand = new DeleteSecretCommand({ + SecretId: this.secretName, + RecoveryWindowInDays: 7, + }); + + await this.client.send(deleteCommand); + } } diff --git a/src/utils.js b/src/utils.js index 29ac31c..782f73c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,35 +1,5 @@ import core from "@actions/core"; - -/** - * Checks if a given value is empty. - * - * @param {*} value - The value to check for emptiness. - * @returns {boolean} - Returns true if the value is empty, false otherwise. - */ -export function isEmpty(value) { - if (value == null || value == undefined) { - // Handles null and undefined - return true; - } - - if (typeof value === "boolean") { - // Boolean values are never empty - return false; - } - - if (typeof value === "number") { - // Number values are never empty - return false; - } - - if (typeof value === "string") { - // Check if the string is empty - return value.trim().length === 0; - } - - // For any other types, assume it's not empty - return false; -} +import lodash from "lodash"; /** * Get action input with default value @@ -40,7 +10,7 @@ export function isEmpty(value) { export function getInput(input, value = "") { const inputValue = core.getInput(input); - if (isEmpty(inputValue)) { + if (lodash.isEmpty(inputValue)) { return value; } @@ -56,7 +26,7 @@ export function getInput(input, value = "") { export function getBooleanInput(input, value = false) { const inputValue = core.getInput(input); - if (isEmpty(inputValue)) { + if (lodash.isEmpty(inputValue)) { return value; } diff --git a/tests/action/ChangeSet.test.js b/tests/action/ChangeSet.test.js index aea36eb..f3d1884 100644 --- a/tests/action/ChangeSet.test.js +++ b/tests/action/ChangeSet.test.js @@ -1,4 +1,4 @@ -import { expect, should } from "chai"; +import { expect } from "chai"; import sinon from "sinon"; import ChangeSet from "../../src/action/ChangeSet.js"; @@ -11,146 +11,190 @@ describe("ChangeSet", () => { secretsManagerStub = sinon.createStubInstance(SecretsManager); }); - it("should create a ChangeSet instance", () => { - const newValues = { key1: "new-value1", key2: "new-value2" }; - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ); - - expect(changeSet).to.be.an.instanceOf(ChangeSet); - }); - - it("should return change descriptions when changeDesc is called", () => { - const newValues = { key1: "new-value1", key2: "new-value2" }; - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ); - - const descriptions = changeSet.changeDesc(); - - expect(descriptions).to.be.an("array"); - expect(descriptions).to.have.lengthOf(2); // Two changes: key1 and key2 - }); - - it("should apply changes when apply is called", async () => { - const newValues = { key1: "new-value1", key2: "new-value2" }; - const existingValues = { key1: "value1", key2: "value2" }; - - secretsManagerStub.update.resolves({}); - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ); - - await changeSet.apply(); - }); - - it("should create change descriptions for modifications", () => { - const newValues = { key1: "new-value1", key2: "new-value2" }; - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ); - - const descriptions = changeSet.changeDesc(); - - expect(descriptions).to.deep.equal([ - "SecretKey: [CHANGED] 'key1': '**********' => '**********'", - "SecretKey: [CHANGED] 'key2': '**********' => '**********'", - ]); - }); - - it("should create change descriptions for removals", () => { - const newValues = { key1: "value1" }; // Key2 is removed - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ); - - const descriptions = changeSet.changeDesc(); - - expect(descriptions).to.deep.equal(["SecretKey: [REMOVED] 'key2'"]); - }); - - it("should create descriptions for new keys", () => { - const newValues = { key1: "value1", key2: "value2", key3: "value3" }; - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ); - - const descriptions = changeSet.changeDesc(); - - expect(descriptions).to.deep.equal([ - "SecretKey: [ADDED] 'key3': '**********'", - ]); - }); - - it("should exclude keys from patterns", () => { - const newValues = { - _excluded: "some", - key1: "value1", - key2: "value2", - key3: "value3", - }; - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ["^_"], - ); - - const descriptions = changeSet.changeDesc(); - - expect(descriptions).to.deep.equal([ - "SecretKey: [SKIP] '_excluded'", - "SecretKey: [ADDED] 'key3': '**********'", - ]); + describe("Sync operations", () => { + it("should create a ChangeSet instance", () => { + const newValues = { key1: "new-value1", key2: "new-value2" }; + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ); + + expect(changeSet).to.be.an.instanceOf(ChangeSet); + }); + + it("should return change descriptions when changeDesc is called", () => { + const newValues = { key1: "new-value1", key2: "new-value2" }; + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ); + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.be.an("array"); + expect(descriptions).to.have.lengthOf(2); // Two changes: key1 and key2 + }); + + it("should apply changes when apply is called", async () => { + const newValues = { key1: "new-value1", key2: "new-value2" }; + const existingValues = { key1: "value1", key2: "value2" }; + + secretsManagerStub.update.resolves({}); + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ); + + await changeSet.apply(); + }); + + it("should create change descriptions for modifications", () => { + const newValues = { key1: "new-value1", key2: "new-value2" }; + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ); + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.deep.equal([ + "SecretKey: [CHANGED] 'key1': '**********' => '**********'", + "SecretKey: [CHANGED] 'key2': '**********' => '**********'", + ]); + }); + + it("should create change descriptions for removals", () => { + const newValues = { key1: "value1" }; // Key2 is removed + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ); + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.deep.equal(["SecretKey: [REMOVED] 'key2'"]); + }); + + it("should create descriptions for new keys", () => { + const newValues = { key1: "value1", key2: "value2", key3: "value3" }; + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ); + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.deep.equal([ + "SecretKey: [ADDED] 'key3': '**********'", + ]); + }); + + it("should exclude keys from patterns", () => { + const newValues = { + _excluded: "some", + key1: "value1", + key2: "value2", + key3: "value3", + }; + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ["^_"], + ); + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.deep.equal([ + "SecretKey: [SKIP] '_excluded'", + "SecretKey: [ADDED] 'key3': '**********'", + ]); + }); + + it("should show real values on logs", () => { + const newValues = { + _excluded: "some", + key1: "value1", + key2: "value2", + key3: "value3", + }; + const existingValues = { key1: "value1", key2: "value2" }; + + const changeSet = new ChangeSet( + secretsManagerStub, + newValues, + existingValues, + ["^_"], + true, + ); + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.deep.equal([ + "SecretKey: [SKIP] '_excluded'", + "SecretKey: [ADDED] 'key3': 'value3'", + ]); + }); }); - it("should show real values on logs", () => { - const newValues = { - _excluded: "some", - key1: "value1", - key2: "value2", - key3: "value3", - }; - const existingValues = { key1: "value1", key2: "value2" }; - - const changeSet = new ChangeSet( - secretsManagerStub, - newValues, - existingValues, - ["^_"], - true, - ); - - const descriptions = changeSet.changeDesc(); - - expect(descriptions).to.deep.equal([ - "SecretKey: [SKIP] '_excluded'", - "SecretKey: [ADDED] 'key3': 'value3'", - ]); + describe("Delete operation", () => { + it("should delete the secret when deleteSecretFlag is set", async () => { + const changeSet = new ChangeSet( + secretsManagerStub, + {}, + {}, + ["^_"], + false, + true, + ); + + secretsManagerStub.delete.resolves({}); + + await changeSet.apply(); + + expect(secretsManagerStub.delete.calledOnce).to.be.true; + + const descriptions = changeSet.changeDesc(); + + expect(descriptions).to.deep.equal(["SecretKey: [REMOVED] 'ALL_KEYS'"]); + }); + + it("should throw an error when deleting the secret fails", async () => { + const changeSet = new ChangeSet( + secretsManagerStub, + {}, + {}, + ["^_"], + false, + true, + ); + + secretsManagerStub.delete.rejects(new Error("Failed to delete secret")); + + try { + await changeSet.apply(); + } catch (error) { + expect(error.message).to.equal("Failed to delete secret"); + } + }); }); }); diff --git a/tests/secrets-manager/SecretsManager.test.js b/tests/secrets-manager/SecretsManager.test.js index 9573a72..b1c7bee 100644 --- a/tests/secrets-manager/SecretsManager.test.js +++ b/tests/secrets-manager/SecretsManager.test.js @@ -188,4 +188,40 @@ describe("SecretsManager", () => { } }); }); + + describe("delete", async () => { + it("should delete secret", async () => { + const secretName = "my-secret"; + + secretsManagerClientStub.resolves({}); + + const secretsManager = new SecretsManager( + "keyId", + "secretKey", + "region", + secretName, + ); + + await secretsManager.delete(); + }); + + it("should handle errors when deleting secret", async () => { + const secretName = "my-secret"; + + secretsManagerClientStub.rejects(new Error("Failed to delete secret")); + + const secretsManager = new SecretsManager( + "keyId", + "secretKey", + "region", + secretName, + ); + + try { + await secretsManager.delete(); + } catch (error) { + expect(error.message).to.equal("Failed to delete secret"); + } + }); + }); });