Commit 53559d63 authored by mohoff's avatar mohoff Committed by chapati

feat: add CLI with encrypt and decrypt commands

parent 0c2e7cef
lib
oclif.manifest.json
# Default output for generated types when strongConfig.load() is called
strong-config.d.ts
......
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs')
const path = require('path')
const project = path.join(__dirname, '../tsconfig.json')
const isDev = fs.existsSync(project)
if (isDev) {
require('ts-node').register({ project, transpileOnly: true })
}
require(`../${isDev ? 'src' : 'lib'}/cli`)
.run()
.catch(require('@oclif/errors/handle'))
@echo off
node "%~dp0\run" %*
......@@ -5,8 +5,26 @@
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"/bin",
"/lib"
],
"bin": {
"strong-config": "./bin/run"
},
"oclif": {
"commands": "./lib/cli/commands",
"bin": "strong-config",
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-warn-if-update-available",
"@oclif/plugin-not-found",
"@oclif/plugin-autocomplete"
],
"warn-if-update-available": {
"timeoutInDays": 7,
"message": "<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>."
}
},
"repository": "git@github.com:strong-config/node.git",
"bugs": "https://github.com/strong-config/node/issues",
"author": "Brickblock Engineering <dev@brickblock.io>",
......@@ -19,7 +37,7 @@
"lint:json": "jsonlint-cli package.json src/**/*.json",
"lint:markdown": "markdownlint **/*.md --ignore node_modules --ignore CHANGELOG.md --ignore .gitlab",
"release": "standard-version --message \"chore(release): %s [ci-release]\"",
"test": "jest",
"test": "yarn dev:importkey && jest --runInBand --verbose",
"todo": "leasot '**/*.ts' --ignore 'node_modules/**/*','lib/**/*','.git/**/*' || true",
"watch": "tsc --watch",
"clean": "rimraf build/",
......@@ -41,45 +59,53 @@
"@types/glob": "^7.1.1",
"@types/jest": "^24.0.23",
"@types/js-yaml": "^3.12.1",
"@types/node": "^12.12.11",
"@types/node": "^12.12.14",
"@types/ora": "^3.2.0",
"@types/pascal-case": "^1.1.2",
"@types/ramda": "^0.26.36",
"@types/shelljs": "^0.8.6",
"@typescript-eslint/eslint-plugin": "^2.8.0",
"@typescript-eslint/parser": "^2.8.0",
"@types/std-mocks": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0",
"commitlint": "^8.2.0",
"cross-env": "^6.0.3",
"eslint": "^6.6.0",
"eslint": "^6.7.2",
"eslint-config-prettier": "^6.7.0",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^23.0.4",
"eslint-plugin-jest": "^23.1.1",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-security": "^1.4.0",
"eslint-plugin-unicorn": "^13.0.0",
"eslint-plugin-unicorn": "^14.0.1",
"husky": "^3.1.0",
"jest": "^24.9.0",
"jsonlint-cli": "^1.0.1",
"leasot": "^9.2.0",
"lint-staged": "^9.4.3",
"leasot": "^9.3.0",
"lint-staged": "^9.5.0",
"markdownlint-cli": "^0.19.0",
"nodemon": "^2.0.0",
"nodemon": "^2.0.1",
"prettier": "^1.19.1",
"rimraf": "^3.0.0",
"shelljs": "^0.8.3",
"standard-version": "^7.0.1",
"ts-jest": "^24.1.0",
"ts-node": "^8.5.2",
"typescript": "^3.7.2",
"ts-jest": "^24.2.0",
"ts-node": "^8.5.4",
"typescript": "^3.7.3",
"yaml-lint": "^1.2.4"
},
"dependencies": {
"@oclif/command": "^1.5.19",
"@oclif/plugin-autocomplete": "^0.1.5",
"@oclif/plugin-help": "^2.2.2",
"@oclif/plugin-not-found": "^1.2.3",
"@oclif/plugin-warn-if-update-available": "^1.7.0",
"ajv": "^6.10.2",
"execa": "^3.3.0",
"execa": "^3.4.0",
"glob": "^7.1.6",
"js-yaml": "^3.13.1",
"json-schema-to-typescript": "^7.1.0",
"ramda": "^0.26.1"
"json-schema-to-typescript": "^8.0.0",
"ora": "^4.0.3",
"ramda": "^0.26.1",
"std-mocks": "^1.0.1"
}
}
jest.mock('./validate')
import stdMocks from 'std-mocks'
import fs from 'fs'
import { validate } from './validate'
const mockedValidate = validate as jest.MockedFunction<typeof validate>
// Expected output (decrypted config) without whitespace to allow for simple comparisons
const expectedDecryptedConfig = `
name: example-project
someField:
optionalField: 123
requiredField: crucial string
someArray:
- joe
- freeman
someSecret: cantSeeMe
`.replace(/\s/g, '')
import Decrypt from './decrypt'
const configPath = 'example/development.yaml'
const outputPath = 'example/development.decrypted.yaml'
const backupPath = 'example/development.backup.yaml'
const schemaPath = 'example/schema.json'
const mockedExit = jest.spyOn(process, 'exit').mockImplementation()
describe('strong-config decrypt', () => {
afterAll(() => {
mockedExit.mockRestore()
})
describe('shows help', () => {
const expectedHelpOutput = expect.arrayContaining([
expect.stringMatching(/ARGUMENTS/),
expect.stringMatching(/USAGE/),
expect.stringMatching(/OPTIONS/),
])
beforeAll(() => {
stdMocks.use()
})
afterAll(() => {
stdMocks.restore()
})
beforeEach(() => {
stdMocks.flush()
})
it('prints the help with --help', async () => {
try {
await Decrypt.run([configPath, outputPath, '--help'])
} catch (error) {}
expect(stdMocks.flush().stdout).toEqual(expectedHelpOutput)
})
it('always prints help with any command having --help', async () => {
try {
await Decrypt.run(['--help'])
} catch (error) {}
expect(stdMocks.flush().stdout).toEqual(expectedHelpOutput)
})
})
describe('handles decryption', () => {
beforeAll(() => {
// Copy encrypted config file so we can restore it later
fs.copyFileSync(configPath, backupPath)
stdMocks.use()
})
beforeEach(() => {
jest.clearAllMocks()
stdMocks.flush()
})
afterEach(() => {
// Delete temporary output file (decrypted config)
try {
console.log('Unlinking')
fs.unlinkSync(outputPath)
} catch (err) {
console.log('No temporary output file found to delete')
}
// Restore backup config
fs.copyFileSync(backupPath, configPath)
})
afterAll(() => {
// Delete backup config
try {
fs.unlinkSync(backupPath)
} catch (err) {
console.log('Could not find backup config file to delete')
}
stdMocks.restore()
})
it('exits with code 0 when successful', async () => {
await Decrypt.run([configPath])
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('decrypts in-place when no output path is passed', async () => {
await Decrypt.run([configPath])
const decryptedConfigAsString = fs.readFileSync(configPath).toString()
expect(decryptedConfigAsString.replace(/\s/g, '')).toBe(
expectedDecryptedConfig
)
})
it('decrypts to unencrypted file when output path is passed', async () => {
await Decrypt.run([configPath, outputPath])
const decryptedConfigAsString = fs.readFileSync(outputPath).toString()
expect(decryptedConfigAsString.replace(/\s/g, '')).toBe(
expectedDecryptedConfig
)
})
it('fails when config file does not exist', async () => {
await Decrypt.run(['non/existing/config.yaml'])
expect(mockedExit).toHaveBeenCalledWith(1)
})
it('decrypts and validates when schema path is passed', async () => {
await Decrypt.run([configPath, '--schema-path', schemaPath])
expect(mockedValidate).toHaveBeenCalledTimes(1)
})
it('fails when no arguments are passed', async () => {
await expect(Decrypt.run([])).rejects.toThrowError(
/Missing 1 required arg/
)
})
it('fails when passed schema file does not exist', async () => {
// Note: This will still decrypt the config. Only after decryption, validation fails.
await Decrypt.run([
configPath,
'--schema-path',
'non/existing/schema.json',
])
// TODO: Why doesn't it return exit code 1 here?
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('shows the decryption status', async () => {
await Decrypt.run([configPath, outputPath])
expect(stdMocks.flush().stderr.join('')).toMatch('💪 Decrypted!')
})
it('shows an error when decryption fails ', async () => {
await Decrypt.run(['non/existing/config.yaml'])
expect(stdMocks.flush().stderr.join('')).toMatch(
/Failed to decrypt config file/
)
})
// TODO: Investigate why 'Validated!' is not available in mocked stderr
test.todo('shows the validation status when schema path is passed')
})
})
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-use-before-define, @typescript-eslint/no-explicit-any */
import { Command, flags } from '@oclif/command'
import { startSpinner, failSpinner, succeedSpinner } from '../spinner'
import { getSopsOptions, runSopsWithOptions } from '../../utils/sops'
import { validate } from './validate'
export default class Decrypt extends Command {
static description = 'decrypt config files'
static strict = true
static flags = {
help: flags.help({
char: 'h',
description: 'show help',
}),
verbose: flags.boolean({
char: 'v',
description: 'print stack traces in case of errors',
default: false,
}),
'schema-path': flags.string({
char: 's',
description:
'path to the schema against which the config will be validated against after decryption. If not specified, validation is skipped. If the schma path is set, the command fails if the encryption succeeded but the validation fails',
required: false,
}),
}
static args = [
{
name: 'config_path',
description: 'path to an encrypted config file',
required: true,
},
{
name: 'output_path',
description:
'output file of the decrypted config. If not specified, the file found at CONFIG_PATH is overwritten in-place.',
required: false,
},
]
static usage =
'decrypt CONFIG_PATH OUTPUT_PATH [--schema-path=SCHEMA_PATH] [--help]'
static examples = [
'$ decrypt config/development.yaml',
'$ decrypt config/production.yaml config/production.decrypted.yaml --schema-path config/schema.json',
'$ decrypt --help',
]
async run() {
const { args, flags } = this.parse(Decrypt)
decrypt(args, flags)
// Validate unencrypted config after decryption
if (flags['schema-path']) {
validate(args['config_path'], flags['schema-path'], flags['verbose'])
}
process.exit(0)
}
}
const decrypt = (
args: Record<string, any>,
flags: Record<string, any>
): void => {
startSpinner('Decrypting...')
const sopsOptions = ['--decrypt', ...getSopsOptions(args, flags)]
try {
runSopsWithOptions(sopsOptions)
} catch (error) {
failSpinner('Failed to decrypt config file', error, flags['isVerbose'])
process.exit(1)
}
succeedSpinner('Decrypted!')
}
jest.mock('./validate')
import stdMocks from 'std-mocks'
import fs from 'fs'
import { validate } from './validate'
const mockedValidate = validate as jest.MockedFunction<typeof validate>
import Encrypt from './encrypt'
// This file is created in the beforeAll handler
const configPath = 'example/development.decrypted.yaml'
const outputPath = 'example/development.encrypted.yaml'
const backupPath = 'example/development.backup.yaml'
const schemaPath = 'example/schema.json'
const keyId = '2E9644A658379349EFB77E895351CE7FC0AC6E94' // example/pgp/example-keypair.pgp
const keyProvider = 'pgp'
const requiredKeyFlags = ['-k', keyId, '-p', keyProvider]
const mockedExit = jest.spyOn(process, 'exit').mockImplementation()
const unencryptedConfig = `name: example-project
someField:
optionalField: 123
requiredField: crucial string
someArray:
- joe
- freeman
someSecret: cantSeeMe
`
const expectedEncryptedConfig = expect.arrayContaining([
'someField:',
'optionalField: 123',
'requiredField: crucial string',
'someArray:',
'- joe',
'- freeman',
expect.stringContaining('someSecret: ENC['),
'sops:',
'kms: []',
'gcp_kms: []',
'azure_kv: []',
'encrypted_suffix: Secret',
])
describe('strong-config encrypt', () => {
afterAll(() => {
mockedExit.mockRestore()
})
describe('shows help', () => {
const expectedHelpOutput = expect.arrayContaining([
expect.stringMatching(/ARGUMENTS/),
expect.stringMatching(/USAGE/),
expect.stringMatching(/OPTIONS/),
])
beforeAll(() => {
stdMocks.use()
})
afterAll(() => {
stdMocks.restore()
})
beforeEach(() => {
stdMocks.flush()
})
it('prints the help with --help', async () => {
try {
await Encrypt.run(['--help'])
} catch (error) {}
expect(stdMocks.flush().stdout).toEqual(expectedHelpOutput)
})
it('always prints help with any command having --help', async () => {
try {
await Encrypt.run([
'some/config/file.yml',
'--help',
'--schema-path',
'path/to/schema.json',
...requiredKeyFlags,
])
} catch (error) {}
expect(stdMocks.flush().stdout).toEqual(expectedHelpOutput)
})
})
describe('handles encryption', () => {
// Note: The PGP key required for encryption is imported in the test command in package.json
beforeAll(() => {
// Create unencrypted config file
fs.writeFileSync(configPath, unencryptedConfig)
// Copy unencrypted config file so we can restore it later
fs.copyFileSync(configPath, backupPath)
stdMocks.use()
})
beforeEach(() => {
jest.clearAllMocks()
stdMocks.flush()
})
afterEach(() => {
// Delete temporary output file (encrypted config)
try {
fs.unlinkSync(outputPath)
} catch (err) {
console.log('No temporary output file found to delete')
}
// Restore backup config
fs.copyFileSync(backupPath, configPath)
})
afterAll(() => {
// Delete unencrypted config and backup config
try {
fs.unlinkSync(configPath)
fs.unlinkSync(backupPath)
} catch (err) {
console.log('Could not find backup config file to delete')
}
stdMocks.restore()
})
it('exits with code 0 when successful', async () => {
await Encrypt.run([configPath, ...requiredKeyFlags])
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('encrypts in-place when no output path is passed', async () => {
await Encrypt.run([configPath, ...requiredKeyFlags])
const encryptedConfigAsString = fs.readFileSync(configPath).toString()
console.log(encryptedConfigAsString)
expect(encryptedConfigAsString.split('\n').map(s => s.trim())).toEqual(
expectedEncryptedConfig
)
})
it('encrypts to encrypted file when output path is passed', async () => {
await Encrypt.run([configPath, outputPath, ...requiredKeyFlags])
const encryptedConfigAsString = fs.readFileSync(outputPath).toString()
expect(encryptedConfigAsString.split('\n').map(s => s.trim())).toEqual(
expectedEncryptedConfig
)
})
it('fails when config file does not exist', async () => {
await Encrypt.run(['non/existing/config.yaml', ...requiredKeyFlags])
expect(mockedExit).toHaveBeenCalledWith(1)
})
it('encrypts and validates when schema path is passed', async () => {
await Encrypt.run([
configPath,
...requiredKeyFlags,
'--schema-path',
schemaPath,
])
expect(mockedValidate).toHaveBeenCalledTimes(1)
})
it('fails when passed schema file does not exist', async () => {
// Note: This will not encrypt the file as validation fails first
await Encrypt.run([
configPath,
...requiredKeyFlags,
'--schema-path',
'non/existing/schema.json',
])
// TODO: Why doesn't it return exit code 1 here?
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('fails when no arguments are passed', async () => {
await expect(Encrypt.run([configPath])).rejects.toThrowError(
/--key-provider KEY-PROVIDER/
)
})
it('fails when no key provider is passed with --key-provider/-p', async () => {
await expect(Encrypt.run([configPath, '-k', keyId])).rejects.toThrowError(
/--key-provider KEY-PROVIDER/
)
})
it('fails when no key id is passed with --key-id/-k', async () => {
await expect(
Encrypt.run([configPath, '-p', keyProvider])
).rejects.toThrowError(/--key-id= must also be provided/)
})
it('shows the encryption status', async () => {
await Encrypt.run([configPath, outputPath, ...requiredKeyFlags])
expect(stdMocks.flush().stderr.join('')).toMatch('💪 Encrypted!')
})
it('shows an error when encryption fails ', async () => {
await Encrypt.run([
'non/existing/config.yaml',
outputPath,
...requiredKeyFlags,
])
expect(stdMocks.flush().stderr.join('')).toMatch(
/Failed to encrypt config file/
)
})
// TODO: Investigate why 'Validated!' is not available in mocked stderr
test.todo('shows the validation status when schema path is passed')
})
})
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-use-before-define, @typescript-eslint/no-explicit-any */
import { Command, flags } from '@oclif/command'
import { startSpinner, failSpinner, succeedSpinner } from '../spinner'
import { getSopsOptions, runSopsWithOptions } from '../../utils/sops'
import { validate } from './validate'
const DEFAULT_ENCRYPTED_KEY_SUFFIX = 'Secret'
const SUPPORTED_KEY_PROVIDERS = ['pgp', 'gcp', 'aws', 'azr']
export default class Encrypt extends Command {
static description = 'encrypt config files'
static strict = true
static flags = {
help: flags.help({
char: 'h',
description: 'show help',
}),
verbose: flags.boolean({
char: 'v',
description: 'print stack traces in case of errors',
default: false,
}),
'key-provider': flags.string({
char: 'p',
description: 'key provider to use to encrypt secrets',
required: true,
options: SUPPORTED_KEY_PROVIDERS,
dependsOn: ['key-id'],
}),
'key-id': flags.string({
char: 'k',
description: 'reference to a unique key managed by the key provider',
required: true,
dependsOn: ['key-provider'],
}),
'encrypted-key-suffix': flags.string({
char: 'e',
description: 'key suffix determining the values to be encrypted',
required: false,
default: DEFAULT_ENCRYPTED_KEY_SUFFIX,
exclusive: ['unencrypted-key-suffix'],
}),
'unencrypted-key-suffix': flags.string({
char: 'u',
description: 'key suffix determining the values to be NOT encrypted',
required: false,
exclusive: ['encrypted-key-suffix'],
}),
'schema-path': flags.string({
char: 's',
description:
'path to the schema against which the config will be validated against prior to encryption. If not specified, validation is skipped.',
required: false,
}),
}
static args = [
{
name: 'config_path',
description: 'path to an unencrypted config file',
required: true,