From 27222deccd66c289d83dde9879b08f9067776ab6 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 02:15:59 +0300 Subject: [PATCH 01/19] Refactors * Adds an EE2 format * Refactors encryption & crypto modules * Introduces new dotenv parser --- package-lock.json | 54 ++++++++++++- package.json | 4 +- src/actions/decrypt.ts | 19 ++--- src/actions/encrypt.ts | 18 ++--- src/actions/init.ts | 2 +- src/config.ts | 4 +- src/crypto.ts | 79 +++++++++++++++++++ src/encoder.ts | 26 ------ src/encryption.ts | 135 ++++++++++++++++++++------------ src/index.ts | 13 +-- src/languages/dotenv/grammar.ts | 71 +++++++++++++++++ src/languages/dotenv/index.ts | 70 +++++++++++++++++ src/languages/dotenv/parser.ts | 99 +++++++++++++++++++++++ src/languages/index.ts | 35 +++++++++ src/languages/yaml/index.ts | 7 ++ src/languages/yaml/parser.ts | 10 +++ src/tokenizer.ts | 14 ---- src/types.ts | 31 ++++++++ tsconfig.json | 2 +- 19 files changed, 567 insertions(+), 126 deletions(-) create mode 100644 src/crypto.ts delete mode 100644 src/encoder.ts create mode 100644 src/languages/dotenv/grammar.ts create mode 100644 src/languages/dotenv/index.ts create mode 100644 src/languages/dotenv/parser.ts create mode 100644 src/languages/index.ts create mode 100644 src/languages/yaml/index.ts create mode 100644 src/languages/yaml/parser.ts delete mode 100644 src/tokenizer.ts create mode 100644 src/types.ts diff --git a/package-lock.json b/package-lock.json index 505e74e..d113928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "commander": "^10.0.0", - "glob": "^9.2.1" + "glob": "^9.2.1", + "peggy": "^3.0.2", + "yaml": "^2.3.2" }, "bin": { "envienc": "dest/index.js" @@ -4923,6 +4925,21 @@ "node": ">=8" } }, + "node_modules/peggy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-3.0.2.tgz", + "integrity": "sha512-n7chtCbEoGYRwZZ0i/O3t1cPr6o+d9Xx4Zwy2LYfzv0vjchMBU0tO+qYYyvZloBPcgRgzYvALzGWHe609JjEpg==", + "dependencies": { + "commander": "^10.0.0", + "source-map-generator": "0.8.0" + }, + "bin": { + "peggy": "bin/peggy.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -5369,6 +5386,14 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==", + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -5990,6 +6015,14 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", @@ -9704,6 +9737,15 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "peggy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-3.0.2.tgz", + "integrity": "sha512-n7chtCbEoGYRwZZ0i/O3t1cPr6o+d9Xx4Zwy2LYfzv0vjchMBU0tO+qYYyvZloBPcgRgzYvALzGWHe609JjEpg==", + "requires": { + "commander": "^10.0.0", + "source-map-generator": "0.8.0" + } + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -10002,6 +10044,11 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true }, + "source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==" + }, "source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -10438,6 +10485,11 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==" + }, "yargs": { "version": "17.7.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", diff --git a/package.json b/package.json index f8baee3..35f2fa5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ }, "dependencies": { "commander": "^10.0.0", - "glob": "^9.2.1" + "glob": "^9.2.1", + "peggy": "^3.0.2", + "yaml": "^2.3.2" } } diff --git a/src/actions/decrypt.ts b/src/actions/decrypt.ts index 1747ded..37ad8cf 100644 --- a/src/actions/decrypt.ts +++ b/src/actions/decrypt.ts @@ -1,17 +1,9 @@ -import { CipherKey } from 'crypto'; import { readFileSync, writeFileSync } from 'fs'; import { readConfig } from '../config'; -import { decrypt, deriveKey } from '../encryption'; +import { ignite } from '../encryption'; import { findEncrypted } from '../glob'; +import getByFilename from '../languages'; import { out, err } from '../output'; -import { Replacer, replaceValues } from '../tokenizer'; -import { decode } from '../encoder'; - -const decrypter = (encryptionKey: CipherKey): Replacer => (entry, key, value) => { - const { authTag, iv, ciphertext } = decode(value); - const decrypted = decrypt(encryptionKey, ciphertext, iv, authTag).toString('utf-8'); - return `${key}=${decrypted}`; -}; /** * Implements "decrypt" action @@ -45,11 +37,12 @@ export default function decryptAction( process.exit(0); } - const key = deriveKey(password, config.salt); - const keyedDecryptor = decrypter(key); + const { decryptor } = ignite(password, config.salt); + const changes: [string, string][] = paths.map(path => { + const { decryptFile } = getByFilename(path); let contents = readFileSync(path, 'utf-8'); - contents = replaceValues(contents, keyedDecryptor); + contents = decryptFile(contents, decryptor); return [path.split('.').slice(0, -1).join('.'), contents]; }); diff --git a/src/actions/encrypt.ts b/src/actions/encrypt.ts index c86318d..fe9798c 100644 --- a/src/actions/encrypt.ts +++ b/src/actions/encrypt.ts @@ -1,16 +1,9 @@ -import { CipherKey } from 'crypto'; import { readFileSync, writeFileSync } from 'fs'; import { readConfig } from '../config'; -import { deriveKey, encrypt } from '../encryption'; +import { ignite } from '../encryption'; import { EXTENSION, findPlaintext } from '../glob'; import { out, err } from '../output'; -import { Replacer, replaceValues } from '../tokenizer'; -import { encode } from '../encoder'; - -const encrypter = (encryptionKey: CipherKey): Replacer => (entry, key, value) => { - const encrypted = encrypt(encryptionKey, Buffer.from(value, 'utf-8')); - return `${key}=${encode(encrypted)}`; -}; +import getByFilename from '../languages'; /** * Implements "encrypt" action @@ -44,11 +37,12 @@ export default function encryptAction( process.exit(0); } - const key = deriveKey(password, config.salt); - const keyedEncrypter = encrypter(key); + const { encryptor } = ignite(password, config.salt); + const changes: [string, string][] = paths.map(path => { + const { encryptFile } = getByFilename(path); let contents = readFileSync(path, 'utf-8'); - contents = replaceValues(contents, keyedEncrypter); + contents = encryptFile(contents, encryptor); return [path.concat(EXTENSION), contents]; }); diff --git a/src/actions/init.ts b/src/actions/init.ts index 3112d9e..cbf50f6 100644 --- a/src/actions/init.ts +++ b/src/actions/init.ts @@ -1,5 +1,5 @@ import { writeConfig } from '../config'; -import { generateSalt } from '../encryption'; +import { generateSalt } from '../crypto'; import { out } from '../output'; /** diff --git a/src/config.ts b/src/config.ts index b979042..8da472f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,7 @@ export type Config = { salt: string; /** - * Dotenv globs + * Files globs */ globs?: string[]; }; @@ -27,7 +27,7 @@ export type Config = { * Recursively find config path recursively up * * Based on https://github.com/mateodelnorte/find-file-recursively-up - * @returns Path to .enviencrc, or "undefined" if not found + * @returns Path to .enviencrc, or undefined if not found */ export function findConfigPath(): string | undefined { function find(entry: string): string | undefined { diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..73efab0 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,79 @@ +import { + CipherGCMTypes, CipherKey, createCipheriv, randomBytes, createDecipheriv, pbkdf2Sync, +} from 'crypto'; + +export type EncryptResult = { + iv: Buffer; + authTag: Buffer; + ciphertext: Buffer; +}; + +/** + * Cipher to use. + * Prefer GCM ciphers for authenticated encryption + */ +const CIPHER: CipherGCMTypes = 'aes-256-gcm'; + +/** + * Number of KDF iterations + */ +const KDF_ITERATIONS = 600000; + +/** + * Size of derived key + */ +const KDF_SIZE = 32; + +/** + * KDF digest + */ +const KDF_DIGEST = 'sha256'; + +/** + * Generates salt for KDF + * @returns Password KDF salt + */ +export function generateSalt(): string { + return randomBytes(16).toString('hex'); +} + +/** + * Perform KDF on password to derieve encryption key + * @param password User-selected password + * @param salt Salt (must be at least 16 bytes) + * @returns Encryption key + */ +export function deriveKey(password: string, salt: string): CipherKey { + return pbkdf2Sync(password, salt, KDF_ITERATIONS, KDF_SIZE, KDF_DIGEST); +} + +/** + * Encrypts data + * @param key Encryption key + * @param plaintext Plaintext (unencrypted) data + * @returns Generated IV and encrypted data + */ +export function encrypt(key: CipherKey, plaintext: Buffer): EncryptResult { + const iv = randomBytes(16); + const cipher = createCipheriv(CIPHER, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { + iv, + authTag, + ciphertext, + }; +} + +/** + * Decrypts data + * @param key Encryption key + * @param ciphertext Encrypted data + * @param iv Initialization vector + * @returns Plaintext (unencrypted) data + */ +export function decrypt(key: CipherKey, ciphertext: Buffer, iv: Buffer, authTag: Buffer): Buffer { + const decipher = createDecipheriv(CIPHER, key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} diff --git a/src/encoder.ts b/src/encoder.ts deleted file mode 100644 index 50dcdfb..0000000 --- a/src/encoder.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EncryptResult } from './encryption'; - -export type Encoded = string; -export type Decoded = EncryptResult; - -/** - * Encodes encryption result & parameters to safe string - * @param params Ciphertext & IV - * @returns Encrypted string - */ -export function encode(params: Decoded): Encoded { - return `${params.iv.toString('hex')}:${params.authTag.toString('hex')}:${params.ciphertext.toString('hex')}`; -} - -/** - * Decodes safe string to encryption result parameters - * @param encoded Safe string - * @returns Ciphertext & IV - */ -export function decode(encoded: Encoded): Decoded { - const [iv, authTag, ciphertext] = encoded - .trim() - .split(':') - .map(c => Buffer.from(c, 'hex')); - return { iv, authTag, ciphertext }; -} diff --git a/src/encryption.ts b/src/encryption.ts index 455e1e7..42c0bf4 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -1,79 +1,114 @@ +import { CipherKey } from 'crypto'; import { - CipherGCMTypes, CipherKey, createCipheriv, randomBytes, createDecipheriv, pbkdf2Sync, -} from 'crypto'; + decrypt, deriveKey, encrypt, EncryptResult, +} from './crypto'; +import { + Data, GenericMetadata, KeyedDecryptor, KeyedEncryptor, +} from './types'; -export type EncryptResult = { - iv: Buffer; - authTag: Buffer; - ciphertext: Buffer; +export type Encoded = string; +export type Decoded = EncryptResult & { + format?: FormatNumber }; /** - * Cipher to use. - * Prefer GCM ciphers for authenticated encryption + * Envienc format number */ -const CIPHER: CipherGCMTypes = 'aes-256-gcm'; +export enum FormatNumber { + V1 = '', + V2 = '$EE2$', +} /** - * Number of KDF iterations + * Encodes encryption result & parameters to safe string + * @param params Ciphertext and parameters + * @returns Encoded ciphertext */ -const KDF_ITERATIONS = 600000; +function encode(params: Decoded): string { + let format = ''; + if (params.format !== FormatNumber.V1) { + // Add version prefix for format v2 and above + format = `${params.format ?? FormatNumber.V2}:`; + } -/** - * Size of derived key - */ -const KDF_SIZE = 32; + const encoding = params.format === FormatNumber.V1 ? 'hex' : 'base64'; + return `${format}${params.iv.toString(encoding)}:${params.authTag.toString(encoding)}:${params.ciphertext.toString(encoding)}`; +} /** - * KDF digest + * Decodes safe string to encryption result parameters + * @param encoded Encoded ciphertext + * @returns Ciphertext and parameters */ -const KDF_DIGEST = 'sha256'; +function decode(encoded: Encoded): Decoded { + let format: FormatNumber = FormatNumber.V1; -/** - * Perform KDF on password to derieve encryption key - * @param password User-selected password - * @param salt Salt (must be at least 16 bytes) - * @returns Encryption key - */ -export function deriveKey(password: string, salt: string): CipherKey { - return pbkdf2Sync(password, salt, KDF_ITERATIONS, KDF_SIZE, KDF_DIGEST); + // Decode parameters + const params = encoded + .trim() + .split(':'); + + // Check is first parameter is version number + if (params[0] === ('$EE2$')) { + params.shift(); + format = FormatNumber.V2; + } + + const encoding = format === FormatNumber.V1 ? 'hex' : 'base64'; + const [iv, authTag, ciphertext] = params.map(value => Buffer.from(value, encoding)); + return { iv, authTag, ciphertext }; } /** - * Generates salt for KDF - * @returns Password KDF salt + * Encrypts the given data using the provided key and metadata. + * @param key The cipher key to use for encryption. + * @param data The data to encrypt. + * @param metadata The metadata to include in the encrypted data. + * @returns The encrypted data as a string. */ -export function generateSalt(): string { - return randomBytes(16).toString('hex'); +function encryptor(key: CipherKey, data: Data, metadata?: GenericMetadata): string { + const plaintext = JSON.stringify({ d: data, m: metadata ?? {} }); + const encrypted = encrypt(key, Buffer.from(plaintext)); + return encode(encrypted); } /** - * Encrypts data - * @param key Encryption key - * @param plaintext Plaintext (unencrypted) data - * @returns Generated IV and encrypted data + * Decrypts an encoded ciphertext using the provided key. + * @param key The key used for decryption. + * @param encodedCiphertext The encoded ciphertext to be decrypted. + * @returns An object containing the decrypted data and metadata. */ -export function encrypt(key: CipherKey, plaintext: Buffer): EncryptResult { - const iv = randomBytes(16); - const cipher = createCipheriv(CIPHER, key, iv); - const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); - const authTag = cipher.getAuthTag(); +function decryptor(key: CipherKey, encodedCiphertext: string) { + const { + ciphertext, iv, authTag, format, + } = decode(encodedCiphertext); + + const plaintext = decrypt(key, ciphertext, iv, authTag); + if (format === FormatNumber.V1) { + return { data: plaintext.toString(), metadata: {} }; + } + + const { d, m } = JSON.parse(plaintext.toString()); return { - iv, - authTag, - ciphertext, + data: d as string, + metadata: m as GenericMetadata, }; } /** - * Decrypts data - * @param key Encryption key - * @param ciphertext Encrypted data - * @param iv Initialization vector - * @returns Plaintext (unencrypted) data + * Derives encryption key from password and salt, and returns keyed encryptor and decryptor + * @param password User-provided password + * @param salt Salt + * @returns Keyed decryptor and encryptor */ -export function decrypt(key: CipherKey, ciphertext: Buffer, iv: Buffer, authTag: Buffer): Buffer { - const decipher = createDecipheriv(CIPHER, key, iv); - decipher.setAuthTag(authTag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +export function ignite(password: string, salt: string): { + encryptor: KeyedEncryptor, + decryptor: KeyedDecryptor +} { + const key = deriveKey(password, salt); + + return { + encryptor: encryptor.bind(null, key), + decryptor: decryptor.bind(null, key), + }; } diff --git a/src/index.ts b/src/index.ts index 74f032e..d77352b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,17 +5,21 @@ import decryptAction from './actions/decrypt'; program .name('envienc') - .description('🔐 Tool for dotenv values encryption'); + .description( + '🔐 Tool for configuration file encryption.\n' + + 'Encrypts only values, but not keys. Supports dotenv and YAML.\n' + + 'That\'s really everything you need to know.', + ); program .command('init') .summary('Generates configuration file') - .option('-g, --glob ', 'glob matching environment files') + .option('-g, --glob ', 'glob matching target files') .action(initAction); program .command('encrypt') - .summary('Encrypts dotenv values') + .summary('Encrypts configuration files') .argument('[globs...]', 'Globs to encrypt. If defined, globs from ".enviencrc" will be ignored') .option('-p, --password ', 'Encryption password. Alternatively can be supplied via "ENVIENC_PWD" environment variable') .option('-e, --exclude [glob]', 'Excluding glob. Files matched with this glob will be skipped') @@ -23,8 +27,7 @@ program program .command('decrypt') - .description('Decrypts dotenv values') - .summary('Encrypts dotenv values') + .summary('Decrypts configuration files') .argument('[globs...]', 'Globs to encrypt. If defined, globs from ".enviencrc" will be ignored') .option('-p, --password ', 'Encryption password. Alternatively can be supplied via "ENVIENC_PWD" environment variable') .option('-e, --exclude [glob]', 'Excluding glob. Files matched with this glob will be skipped') diff --git a/src/languages/dotenv/grammar.ts b/src/languages/dotenv/grammar.ts new file mode 100644 index 0000000..e01df95 --- /dev/null +++ b/src/languages/dotenv/grammar.ts @@ -0,0 +1,71 @@ +const grammar = ` +Lines + = lines:(Line / CommentLine / EmptyLine)* { + let result = lines.reduce((acc, item) => { + if (item.type === 'comment' || item.type === 'emptyLine') { + acc._tempComments = acc._tempComments || []; + acc._tempComments.push(item.value); + } else if (item.type === 'line') { + acc[item.key] = { value: item.value }; + if (item.followedByNewline) { + acc[item.key].followedByNewline = item.followedByNewline; + } + if (item.multilineMode) { + acc[item.key].multilineMode = item.multilineMode; + } + if (acc._tempComments) { + acc[item.key].comments = acc._tempComments; + delete acc._tempComments; + } + } + return acc; + }, {}); + + // Assign remaining _tempComments to __orphanComments + if (result._tempComments) { + result.__orphanComments = result._tempComments; + delete result._tempComments; + } + + return result; + } + +Line + = _ line:StrippedLine _ "\\n"* { return line } + +CommentLine + = _ comment:Comment _ "\\n"? { return {type: 'comment', value: comment} } + +EmptyLine + = _ "\\n" { return {type: 'emptyLine', value: ""} } + +StrippedLine "line with whitespace stripped out" + = key:Key "=" value:Value followedByNewline:FollowedByNewline? { return {type: 'line', key, ...value, followedByNewline: !!followedByNewline} } + / key:Key { return {type: 'line', key, value: true} } // Handle key-only entries + +FollowedByNewline + = "\\n" "\\n"+ { return true } + +Comment + = "#" comment:[^\\n]* { return comment.join('') } + +Key + = first:[a-zA-Z_] rest:[a-zA-Z_0-9]* { return first + rest.join('') } + +Value + = "'" val:([^'\\n]*) "'" _ Comment? { return {value: val.join('')} } + / '"' val:MultiLineValue '"' _ Comment? { + const value = val.replace('\\\\n','\\n'); + const multilineMode = val.includes('\\\\n') ? 'ESCAPE' : (val.includes('\\n') ? 'RESOLVE' : undefined); + return {value, multilineMode}; + } + / val:([^\\n#]*) _ Comment? { return {value: val.join('').trim()} } + +MultiLineValue + = chars:( [^"] / "\\\\\\"" / '\\n' )+ { return chars.join('') } + +_ "whitespace" + = [ \\t\\r]* +`; + +export default grammar; diff --git a/src/languages/dotenv/index.ts b/src/languages/dotenv/index.ts new file mode 100644 index 0000000..ea443e9 --- /dev/null +++ b/src/languages/dotenv/index.ts @@ -0,0 +1,70 @@ +import { DecryptFile, EncryptFile } from '../../types'; +import { + EnvTreeNode, parse, stringify, RESERVED_KEYS, +} from './parser'; + +export type Metadata = { + multilineMode?: 'RESOLVE' | 'ESCAPE', + followedByNewline?: boolean, +}; + +const encryptFile: EncryptFile = (file, encryptor) => { + const content = parse(file); + + // Encrypt each node + const nodes = Object + .entries(content) + .map(([key, value]) => { + // Skip reserved keys + if (RESERVED_KEYS.includes(key)) { + return [key, value]; + } + + const node = value as EnvTreeNode; + return [ + key, + { + ...node, + // Encrypt value + value: encryptor(node.value, { + multilineMode: node.multilineMode, + followedByNewline: node.followedByNewline, + }), + }, + ]; + }); + + return stringify(Object.fromEntries(nodes)); +}; + +const decryptFile: DecryptFile = (file, decryptor) => { + const content = parse(file); + + // Decrypt each node + const nodes = Object + .entries(content) + .map(([key, entry]) => { + // Skip reserved keys + if (RESERVED_KEYS.includes(key)) { + return [key, entry]; + } + + const node = entry as EnvTreeNode; + const { data, metadata } = decryptor(node.value as string); + const meta = metadata as Metadata; + return [ + key, + { + ...node, + value: data, + // Fallback to "ESCAPE" mode if not specified + multilineMode: meta?.multilineMode ?? 'ESCAPE', + followedByNewline: meta?.followedByNewline, + }, + ]; + }); + + return stringify(Object.fromEntries(nodes)); +}; + +export default { encryptFile, decryptFile }; diff --git a/src/languages/dotenv/parser.ts b/src/languages/dotenv/parser.ts new file mode 100644 index 0000000..f10f46a --- /dev/null +++ b/src/languages/dotenv/parser.ts @@ -0,0 +1,99 @@ +import { generate } from 'peggy'; +import { Data } from '../../types'; +import grammar from './grammar'; + +const parser = generate(grammar); + +/** + * Some utility data returned by the parser is not needed in the final result. + */ +export const RESERVED_KEYS = ['__orphanComments']; + +/** + * Type represents different approaches to newline handling. + * - `RESOLVE` - newlines are resolved as is + * - `ESCAPE` - newlines are escaped with backslash (`\n`) + */ +export type MultilineMode = 'RESOLVE' | 'ESCAPE'; + +/** + * Dotenv file tree node + */ +export type EnvTreeNode = { + comments?: string[]; + value: Data; + multilineMode?: MultilineMode, + followedByNewline: boolean, +}; + +/** + * Dotenv file tree + */ +export type EnvTree = Record & { __orphanComments?: string[] }; + +/** + * Parses .env file into a dotenv file tree + * @param content Raw dotenv content + * @returns Dotenv tree + */ +const parse = (content: string): EnvTree => parser.parse(content) as EnvTree; + +/** + * Stringifies a Dotenv tree into a .env file contents + * @param content Dotenv file tree + * @returns Raw dotenv content + */ +const stringify = (content: EnvTree): string => { + const nodes = Object + .entries(content) + .filter(([key]) => !RESERVED_KEYS.includes(key)) as [string, EnvTreeNode][]; + + const entries = nodes.map(([key, value]) => { + let entry = ''; + const trailing = value.followedByNewline ? '\n' : ''; + + // Comments + if (value.comments?.length) { + // Only add leading "#" to non-empty comments + entry += value.comments.map(c => (c.length ? `#${c}` : '')).join('\n'); + entry += '\n'; + } + + // Key + entry += `${key}`; + + // Flag value + if (value.value === true) { + entry += '='; + return entry + trailing; + } + + // Value w/out multiline + if (!value.multilineMode) { + entry += `="${value.value}"`; + return entry + trailing; + } + + // Multiline value + if (value.multilineMode === 'RESOLVE') { + // For "RESOLVE" mode, put newlines as is + entry += `="${value.value.replace(/\\n/gm, '\n')}"`; + return entry + trailing; + } + + // For "ESCAPE" mode, escape newlines with backslash + entry += `="${value.value.replace(/\n/gm, '\\n')}"`; + return entry + trailing; + }); + + // Apply orphan comments + // eslint-disable-next-line no-underscore-dangle + const orphanComments = content.__orphanComments; + if (orphanComments?.length) { + entries.push(...orphanComments.map(c => (c.length ? `#${c}` : ''))); + } + + return entries.join('\n'); +}; + +export { parse, stringify }; diff --git a/src/languages/index.ts b/src/languages/index.ts new file mode 100644 index 0000000..4711dda --- /dev/null +++ b/src/languages/index.ts @@ -0,0 +1,35 @@ +import dotenv from './dotenv'; +import yaml from './yaml'; + +const YAML_EXTENSIONS = ['.yml', '.yaml']; + +const getNthExtension = (filename: string, n: number) => { + let dotIndex = filename.lastIndexOf('.'); + for (let i = 1; i < n; i += 1) { + dotIndex = filename.lastIndexOf('.', dotIndex - 1); + if (dotIndex === -1) { + return ''; + } + } + const extension = filename.slice(dotIndex + 1); + return extension; +}; + +const getByFilename = (filename: string) => { + const extension = getNthExtension(filename, 1); + if (extension === 'envienc') { + const originalExtension = getNthExtension(filename, 2); + if (YAML_EXTENSIONS.includes(originalExtension)) { + return yaml; + } + return dotenv; + } + + if (YAML_EXTENSIONS.includes(extension)) { + return yaml; + } + + return dotenv; +}; + +export default getByFilename; diff --git a/src/languages/yaml/index.ts b/src/languages/yaml/index.ts new file mode 100644 index 0000000..9b768e2 --- /dev/null +++ b/src/languages/yaml/index.ts @@ -0,0 +1,7 @@ +import { DecryptFile, EncryptFile } from '../../types'; + +const encryptFile: EncryptFile = (file, encryptor) => file; + +const decryptFile: DecryptFile = (file, decryptor) => file; + +export default { encryptFile, decryptFile }; diff --git a/src/languages/yaml/parser.ts b/src/languages/yaml/parser.ts new file mode 100644 index 0000000..a877f7b --- /dev/null +++ b/src/languages/yaml/parser.ts @@ -0,0 +1,10 @@ +import { Document, parseDocument } from 'yaml'; +// import { readFileSync } from 'fs'; + +// const contents = readFileSync('/Users/imcatwhocode/input.yaml', 'utf8'); +// const body = parseDocument(contents); + +/* +const parse = (content: string) => parseDocument(content); + +const stringify = (document: Document.Parsed) => document.toString(); diff --git a/src/tokenizer.ts b/src/tokenizer.ts deleted file mode 100644 index 3f58d05..0000000 --- a/src/tokenizer.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Generic dotenv values replacer - */ -export type Replacer = (entry: string, key: string, value: string) => string; - -/** - * Replaces dotenv values with Replacer function - * @param text Input dotenv contents - * @param replacer Values transformation function - * @returns Dotenv with values replaced - */ -export function replaceValues(dotenv: string, replacer: Replacer): string { - return dotenv.replaceAll(/^([a-zA-Z_]{1,}[a-zA-Z0-9_]{0,})=(.*)$/gm, replacer); -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2d84e3c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,31 @@ +export type EncryptFile = (file: string, encryptor: KeyedEncryptor) => string; +export type DecryptFile = (file: string, decryptor: KeyedDecryptor) => string; + +/** + * Type representing generic metadata object for Envienc v2 format + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GenericMetadata = Record; + +/** + * Type representing generic data entry + */ +export type Data = string | true; + +/** + * Encrypts data and metadata + * @param data Configuration value + * @param metadata Metadata related to this value + * @returns Encrypted & encoded string + */ +export type KeyedEncryptor = (data: Data, metadata?: GenericMetadata) => string; + +/** + * Decrypts data and metadata + * @param encodedCiphertext Encrypted & encoded string + * @returns Configuration value and metadata + */ +export type KeyedDecryptor = (encodedCiphertext: string) => { + data: Data, + metadata: GenericMetadata +}; diff --git a/tsconfig.json b/tsconfig.json index fd5fce1..ade17b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": false, - "sourceMap": false, + "sourceMap": false }, "files": ["src/index.ts"] } From 967d5b23518895293ed9942eaaf4a04ff814a27d Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 02:37:21 +0300 Subject: [PATCH 02/19] Refines file type detector --- src/languages/index.ts | 55 +++++++++++++++++++++++++----------------- src/types.ts | 5 ++++ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/languages/index.ts b/src/languages/index.ts index 4711dda..e9ba054 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -1,35 +1,46 @@ +import { Parser } from '../types'; import dotenv from './dotenv'; import yaml from './yaml'; -const YAML_EXTENSIONS = ['.yml', '.yaml']; +/** + * Simple heuristic to determine if a file is YAML-like. + * Rules: + * - Start with optional whitespace + * - Have non-whitespace, non-hash characters before a colon + * - Have a colon followed by whitespace and then any character + */ +const YAML_LIKE_REGEX = /^\s*[^#\s]+:\s*.+$/; -const getNthExtension = (filename: string, n: number) => { - let dotIndex = filename.lastIndexOf('.'); - for (let i = 1; i < n; i += 1) { - dotIndex = filename.lastIndexOf('.', dotIndex - 1); - if (dotIndex === -1) { - return ''; - } +/** + * Simple heuristic to determine if a file is dotenv-like + */ +const DOTENV_LIKE_REGEX = /^[A-Z_]+=.+$/gm; + +const getParserUsingHeuristics = (name: string, contents: string): Parser => { + if (contents.match(YAML_LIKE_REGEX)) { + return yaml; } - const extension = filename.slice(dotIndex + 1); - return extension; -}; -const getByFilename = (filename: string) => { - const extension = getNthExtension(filename, 1); - if (extension === 'envienc') { - const originalExtension = getNthExtension(filename, 2); - if (YAML_EXTENSIONS.includes(originalExtension)) { - return yaml; - } + if (contents.match(DOTENV_LIKE_REGEX)) { return dotenv; } - if (YAML_EXTENSIONS.includes(extension)) { + throw new Error(`Could not determine file type: ${name}`); +}; + +export default function getParser(name: string, contents: string): Parser { + // For encrypted files, drop the envienc extension and dive into recursion + if (name.endsWith('.envienc')) { + return getParser(name.split('.').slice(0, -1).join('.'), contents); + } + + if (name.endsWith('.yml') || name.endsWith('.yaml')) { return yaml; } - return dotenv; -}; + if (name.startsWith('.env')) { + return dotenv; + } -export default getByFilename; + return getParserUsingHeuristics(name, contents); +} diff --git a/src/types.ts b/src/types.ts index 2d84e3c..8f1da81 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,11 @@ export type EncryptFile = (file: string, encryptor: KeyedEncryptor) => string; export type DecryptFile = (file: string, decryptor: KeyedDecryptor) => string; +export type Parser = { + encryptFile: EncryptFile, + decryptFile: DecryptFile +}; + /** * Type representing generic metadata object for Envienc v2 format */ From adf64602cf0494cc7651cd2a5d4287fe3cde9d64 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 05:28:48 +0300 Subject: [PATCH 03/19] Implements YAML parser and @envienc flags support --- src/actions/decrypt.ts | 4 +- src/actions/encrypt.ts | 4 +- src/flags.ts | 33 +++++++++++++++ src/languages/index.ts | 4 +- src/languages/yaml/index.ts | 82 ++++++++++++++++++++++++++++++++++-- src/languages/yaml/parser.ts | 17 +++++--- src/types.ts | 10 +++++ 7 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 src/flags.ts diff --git a/src/actions/decrypt.ts b/src/actions/decrypt.ts index 37ad8cf..d48c03c 100644 --- a/src/actions/decrypt.ts +++ b/src/actions/decrypt.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'fs'; import { readConfig } from '../config'; import { ignite } from '../encryption'; import { findEncrypted } from '../glob'; -import getByFilename from '../languages'; +import { getParser } from '../languages'; import { out, err } from '../output'; /** @@ -40,8 +40,8 @@ export default function decryptAction( const { decryptor } = ignite(password, config.salt); const changes: [string, string][] = paths.map(path => { - const { decryptFile } = getByFilename(path); let contents = readFileSync(path, 'utf-8'); + const { decryptFile } = getParser(path, contents); contents = decryptFile(contents, decryptor); return [path.split('.').slice(0, -1).join('.'), contents]; }); diff --git a/src/actions/encrypt.ts b/src/actions/encrypt.ts index fe9798c..e86a83a 100644 --- a/src/actions/encrypt.ts +++ b/src/actions/encrypt.ts @@ -3,7 +3,7 @@ import { readConfig } from '../config'; import { ignite } from '../encryption'; import { EXTENSION, findPlaintext } from '../glob'; import { out, err } from '../output'; -import getByFilename from '../languages'; +import { getParser } from '../languages'; /** * Implements "encrypt" action @@ -40,8 +40,8 @@ export default function encryptAction( const { encryptor } = ignite(password, config.salt); const changes: [string, string][] = paths.map(path => { - const { encryptFile } = getByFilename(path); let contents = readFileSync(path, 'utf-8'); + const { encryptFile } = getParser(path, contents); contents = encryptFile(contents, encryptor); return [path.concat(EXTENSION), contents]; }); diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 0000000..c075870 --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,33 @@ +import { ParserCommentOpts } from './types'; +import { err } from './output'; + +/** + * Parses comments with envienc flags + * @param comments Comment lines, omitting the leading `#` or other language-specific markers + * @returns Flags found in the comments + */ +export default function parseCommentFlags( + ...comments: (string | undefined | null)[] +): ParserCommentOpts { + const opts: ParserCommentOpts = {}; + + comments + .filter(a => typeof a === 'string' && a.length) + .map(a => a.trim()) + .filter(a => a.startsWith('@envienc')) + .forEach(entry => { + const ruleset = entry.split(' ').slice(1); + ruleset.forEach(rule => { + switch (rule) { + case 'no-encrypt': + opts.noEncrypt = true; + break; + default: + err('⚠️ Found unsupported comment flag:', rule); + break; + } + }); + }); + + return opts; +} diff --git a/src/languages/index.ts b/src/languages/index.ts index e9ba054..ad8261f 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -28,7 +28,7 @@ const getParserUsingHeuristics = (name: string, contents: string): Parser => { throw new Error(`Could not determine file type: ${name}`); }; -export default function getParser(name: string, contents: string): Parser { +export function getParser(name: string, contents: string): Parser { // For encrypted files, drop the envienc extension and dive into recursion if (name.endsWith('.envienc')) { return getParser(name.split('.').slice(0, -1).join('.'), contents); @@ -44,3 +44,5 @@ export default function getParser(name: string, contents: string): Parser { return getParserUsingHeuristics(name, contents); } + +export { getParserUsingHeuristics }; diff --git a/src/languages/yaml/index.ts b/src/languages/yaml/index.ts index 9b768e2..b600b87 100644 --- a/src/languages/yaml/index.ts +++ b/src/languages/yaml/index.ts @@ -1,7 +1,83 @@ -import { DecryptFile, EncryptFile } from '../../types'; +import YAML, { + isAlias, isCollection, isPair, isScalar, Scalar, +} from 'yaml'; +import { Data, DecryptFile, EncryptFile } from '../../types'; +import { parse, stringify } from './parser'; +import parseCommentFlags from '../../flags'; -const encryptFile: EncryptFile = (file, encryptor) => file; +/** + * Determines whether to skip a YAML node based on its comments. + * @param comments Comments associated with the YAML node. + * @returns Whether to skip the YAML node. + */ +const shouldSkip = (...comments: (string | undefined | null)[]): boolean => { + const flags = parseCommentFlags(...comments); + return flags.noEncrypt ?? false; +}; -const decryptFile: DecryptFile = (file, decryptor) => file; +/** + * Traverses a YAML tree and applies a callback to each scalar node. + * @param cb The callback to apply to each scalar node. + * @param node The YAML node to traverse. + */ +const traverse = (cb: (input: string) => Data, node?: YAML.Node) => { + // Skip empty nodes + if (!node) { + return; + } + + // Skip nodes flagged with `no-encrypt` + if (shouldSkip(node.commentBefore, node.comment)) { + return; + } + + // Skip aliases + if (isAlias(node)) { + // Do nothing with aliases + return; + } + + // Encrypt scalars + if (isScalar(node)) { + // Reassigning the value is the intended way to mutate the tree + // eslint-disable-next-line no-param-reassign + (node as Scalar).value = cb(node.value as string); + return; + } + + // Traverse into Pairs' values + if (isPair(node)) { + const pair = node as YAML.Pair; + + // For Pair, we need to check both key and value comments for flags + if (shouldSkip(pair.key.comment, pair.key.commentBefore)) { + return; + } + + traverse(cb, pair.value ?? undefined); + return; + } + + // Traverse into Collections' items + if (isCollection(node)) { + // That's OK to use for-of on this data structure + // eslint-disable-next-line no-restricted-syntax + for (const pair of node.items) { traverse(cb, pair as YAML.Node); } + } +}; + +const encryptFile: EncryptFile = (file, encryptor) => { + const tree = parse(file); + traverse(encryptor, tree.contents); + return stringify(tree); +}; + +const decryptFile: DecryptFile = (file, decryptor) => { + const tree = parse(file); + const decrypt = (input: string) => decryptor(input).data; + + traverse(decrypt, tree.contents); + return stringify(tree); +}; export default { encryptFile, decryptFile }; diff --git a/src/languages/yaml/parser.ts b/src/languages/yaml/parser.ts index a877f7b..0bb803c 100644 --- a/src/languages/yaml/parser.ts +++ b/src/languages/yaml/parser.ts @@ -1,10 +1,15 @@ import { Document, parseDocument } from 'yaml'; -// import { readFileSync } from 'fs'; -// const contents = readFileSync('/Users/imcatwhocode/input.yaml', 'utf8'); -// const body = parseDocument(contents); +/** + * We don't know (and care) about the contents of the file, so we use `any`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Contents = any; -/* -const parse = (content: string) => parseDocument(content); +const parse = (content: string) => parseDocument(content); -const stringify = (document: Document.Parsed) => document.toString(); +const stringify = ( + document: Document.Parsed | Document, +) => document.toString(); + +export { parse, stringify }; diff --git a/src/types.ts b/src/types.ts index 8f1da81..a32f833 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,16 @@ export type Parser = { decryptFile: DecryptFile }; +/** + * Additional options retrieved from comments in the file by Parser + */ +export type ParserCommentOpts = { + /** + * Disables encryption of the particular node + */ + noEncrypt?: boolean; +}; + /** * Type representing generic metadata object for Envienc v2 format */ From 8f3483da423cccb5daa42543adfbc8bc3df6df3f Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 05:34:27 +0300 Subject: [PATCH 04/19] Add "no-encrypt" flag support to Dotenv parser --- src/flags.ts | 12 +++++++++++- src/languages/dotenv/index.ts | 9 +++++++++ src/languages/yaml/index.ts | 12 +----------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/flags.ts b/src/flags.ts index c075870..c407bfb 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -6,7 +6,7 @@ import { err } from './output'; * @param comments Comment lines, omitting the leading `#` or other language-specific markers * @returns Flags found in the comments */ -export default function parseCommentFlags( +export function parseCommentFlags( ...comments: (string | undefined | null)[] ): ParserCommentOpts { const opts: ParserCommentOpts = {}; @@ -31,3 +31,13 @@ export default function parseCommentFlags( return opts; } + +/** + * Determines whether to skip a node based on its comments. + * @param comments Comments associated with the YAML node. + * @returns Whether to skip the YAML node. + */ +export function shouldSkip(...comments: (string | undefined | null)[]): boolean { + const flags = parseCommentFlags(...comments); + return flags.noEncrypt ?? false; +} diff --git a/src/languages/dotenv/index.ts b/src/languages/dotenv/index.ts index ea443e9..fb684b7 100644 --- a/src/languages/dotenv/index.ts +++ b/src/languages/dotenv/index.ts @@ -1,3 +1,4 @@ +import { shouldSkip } from '../../flags'; import { DecryptFile, EncryptFile } from '../../types'; import { EnvTreeNode, parse, stringify, RESERVED_KEYS, @@ -21,6 +22,10 @@ const encryptFile: EncryptFile = (file, encryptor) => { } const node = value as EnvTreeNode; + if (node.comments && shouldSkip(...node.comments)) { + return [key, node]; + } + return [ key, { @@ -50,6 +55,10 @@ const decryptFile: DecryptFile = (file, decryptor) => { } const node = entry as EnvTreeNode; + if (node.comments && shouldSkip(...node.comments)) { + return [key, node]; + } + const { data, metadata } = decryptor(node.value as string); const meta = metadata as Metadata; return [ diff --git a/src/languages/yaml/index.ts b/src/languages/yaml/index.ts index b600b87..da57481 100644 --- a/src/languages/yaml/index.ts +++ b/src/languages/yaml/index.ts @@ -1,19 +1,9 @@ import YAML, { isAlias, isCollection, isPair, isScalar, Scalar, } from 'yaml'; +import { shouldSkip } from '../../flags'; import { Data, DecryptFile, EncryptFile } from '../../types'; import { parse, stringify } from './parser'; -import parseCommentFlags from '../../flags'; - -/** - * Determines whether to skip a YAML node based on its comments. - * @param comments Comments associated with the YAML node. - * @returns Whether to skip the YAML node. - */ -const shouldSkip = (...comments: (string | undefined | null)[]): boolean => { - const flags = parseCommentFlags(...comments); - return flags.noEncrypt ?? false; -}; /** * Traverses a YAML tree and applies a callback to each scalar node. From a9150785ef96833fa6835a9e4b9884e185dce05b Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 05:37:06 +0300 Subject: [PATCH 05/19] Update package version to 2.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- scripts/build-shebang.js | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d113928..90d8ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "envienc", - "version": "1.4.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "envienc", - "version": "1.4.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "commander": "^10.0.0", diff --git a/package.json b/package.json index 35f2fa5..0bf5cbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "envienc", - "version": "1.4.0", + "version": "2.0.0", "description": "🔐 Tool for dotenv values encryption", "main": "dest/index.js", "repository": "https://github.com/imcatwhocode/encenv", diff --git a/scripts/build-shebang.js b/scripts/build-shebang.js index f724708..db20d51 100644 --- a/scripts/build-shebang.js +++ b/scripts/build-shebang.js @@ -1,4 +1,3 @@ -/* eslint-disable */ const { readFileSync, writeFileSync, chmodSync } = require('fs'); const { join } = require('path'); From 02facac27c7e5f244f4f2e88bc911a8902884b02 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:14:11 +0300 Subject: [PATCH 06/19] Implements interactive password prompt --- package-lock.json | 41 ++++++++++++++-- package.json | 1 + src/actions/decrypt.ts | 20 ++++++-- src/actions/encrypt.ts | 20 ++++++-- tests/encoder/encoder.test.ts | 25 ---------- tests/encryption/encryption.test.ts | 34 -------------- tests/tokenizer/tokenizer.test.ts | 72 ----------------------------- 7 files changed, 67 insertions(+), 146 deletions(-) delete mode 100644 tests/encoder/encoder.test.ts delete mode 100644 tests/encryption/encryption.test.ts delete mode 100644 tests/tokenizer/tokenizer.test.ts diff --git a/package-lock.json b/package-lock.json index 90d8ba9..4e84193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "commander": "^10.0.0", + "enquirer": "^2.4.1", "glob": "^9.2.1", "peggy": "^3.0.2", "yaml": "^2.3.2" @@ -1691,6 +1692,14 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1722,7 +1731,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2323,6 +2331,18 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5490,7 +5510,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7338,6 +7357,11 @@ "uri-js": "^4.2.2" } }, + "ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" + }, "ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7358,8 +7382,7 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "4.3.0", @@ -7790,6 +7813,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "requires": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + } + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -10129,7 +10161,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } diff --git a/package.json b/package.json index 0bf5cbd..aa5454d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "commander": "^10.0.0", + "enquirer": "^2.4.1", "glob": "^9.2.1", "peggy": "^3.0.2", "yaml": "^2.3.2" diff --git a/src/actions/decrypt.ts b/src/actions/decrypt.ts index d48c03c..b3983e4 100644 --- a/src/actions/decrypt.ts +++ b/src/actions/decrypt.ts @@ -1,3 +1,4 @@ +import { prompt } from 'enquirer'; import { readFileSync, writeFileSync } from 'fs'; import { readConfig } from '../config'; import { ignite } from '../encryption'; @@ -9,20 +10,29 @@ import { out, err } from '../output'; * Implements "decrypt" action * @param opts Arguments */ -export default function decryptAction( +export default async function decryptAction( globs: string[], { password: passwordArgument, exclude }: { password?: string, exclude?: string }, -): never { +): Promise { const config = readConfig(); - const password = passwordArgument || process.env.ENVIENC_PWD; + let password = passwordArgument || process.env.ENVIENC_PWD; if (!config) { err('📛 Configuration file is missing. Initialize first with "envienc init"'); process.exit(1); } if (!password) { - err('📛 Password is missing. Provide it via "-p " argument, or "ENVIENC_PWD" environment variable'); - process.exit(1); + try { + const input = await prompt<{ password: string }>({ type: 'password', name: 'password', message: '🔑 Encryption password:' }); + if (!input.password) { + throw new Error('Password is missing. Provide it via "-p " argument, "ENVIENC_PWD" environment variable or enter manually on prompt.'); + } + + password = input.password; + } catch (error) { + err('📛', error); + process.exit(1); + } } if (!config.globs?.length && !globs?.length) { diff --git a/src/actions/encrypt.ts b/src/actions/encrypt.ts index e86a83a..364908f 100644 --- a/src/actions/encrypt.ts +++ b/src/actions/encrypt.ts @@ -1,4 +1,5 @@ import { readFileSync, writeFileSync } from 'fs'; +import { prompt } from 'enquirer'; import { readConfig } from '../config'; import { ignite } from '../encryption'; import { EXTENSION, findPlaintext } from '../glob'; @@ -9,20 +10,29 @@ import { getParser } from '../languages'; * Implements "encrypt" action * @param opts Arguments */ -export default function encryptAction( +export default async function encryptAction( globs: string[], { password: passwordArgument, exclude }: { password?: string, exclude?: string }, -): never { +): Promise { const config = readConfig(); - const password = passwordArgument || process.env.ENVIENC_PWD; + let password = passwordArgument || process.env.ENVIENC_PWD; if (!config) { err('📛 Configuration file is missing. Initialize first with "envienc init"'); process.exit(1); } if (!password) { - err('📛 Password is missing. Provide it via "-p " argument, or "ENVIENC_PWD" environment variable'); - process.exit(1); + try { + const input = await prompt<{ password: string }>({ type: 'password', name: 'password', message: '🔑 Encryption password:' }); + if (!input.password) { + throw new Error('Password is missing. Provide it via "-p " argument, "ENVIENC_PWD" environment variable or enter manually on prompt.'); + } + + password = input.password; + } catch (error) { + err('📛', error); + process.exit(1); + } } if (!config.globs?.length && !globs?.length) { diff --git a/tests/encoder/encoder.test.ts b/tests/encoder/encoder.test.ts deleted file mode 100644 index 215396a..0000000 --- a/tests/encoder/encoder.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { pseudoRandomBytes } from 'crypto'; -import { Decoded, encode, decode } from '../../src/encoder'; - -const decoded: Decoded = { - iv: pseudoRandomBytes(16), - authTag: pseudoRandomBytes(16), - ciphertext: pseudoRandomBytes(128), -}; - -const wellKnown = { - encoded: 'dEAdbEeF:aaaabbbb:8BADf00d', - decoded: { - iv: Buffer.from('deadbeef', 'hex'), - authTag: Buffer.from('aaaabbbb', 'hex'), - ciphertext: Buffer.from('8badf00d', 'hex'), - }, -}; - -test('encodes & decodes correctly', () => { - expect(decode(encode(decoded))).toStrictEqual(decoded); -}); - -test('decodes well-known string correctly', () => { - expect(decode(wellKnown.encoded)).toStrictEqual(wellKnown.decoded); -}); diff --git a/tests/encryption/encryption.test.ts b/tests/encryption/encryption.test.ts deleted file mode 100644 index 593e44b..0000000 --- a/tests/encryption/encryption.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { pseudoRandomBytes } from 'crypto'; -import { encrypt, decrypt, deriveKey } from '../../src/encryption'; - -test('derives key correctly', () => { - const password = 'foobar'; - const salt = '1234567890abcdef'; - const expectedKey = Buffer.from('2f95a26c4fbf2b168839985c9f4542161901a0393e5b0a3dd5d03a2912bafead', 'hex'); - expect(deriveKey(password, salt)).toEqual(expectedKey); -}); - -test('encrypts & decrypts correctly', () => { - const password = pseudoRandomBytes(16).toString('base64'); - const salt = pseudoRandomBytes(16).toString('hex'); - const random = pseudoRandomBytes(64); - const key = deriveKey(password, salt); - - const { ciphertext, iv, authTag } = encrypt(key, random); - const plaintext = decrypt(key, ciphertext, iv, authTag); - expect(plaintext).toEqual(random); -}); - -test('decrypts well known data correctly', () => { - const plaintext = Buffer.from('Hello World', 'utf8'); - const password = 'foobar'; - const salt = '1234567890abcdef'; - - const authTag = Buffer.from('62b5290d5613c5077017cb99639345e3', 'hex'); - const iv = Buffer.from('49a5189f1859093d3a2d5a1fb17663af', 'hex'); - const ciphertext = Buffer.from('57d9df5f5f0ab80d590942', 'hex'); - - const key = deriveKey(password, salt); - const decrypted = decrypt(key, ciphertext, iv, authTag); - expect(plaintext).toEqual(decrypted); -}); diff --git a/tests/tokenizer/tokenizer.test.ts b/tests/tokenizer/tokenizer.test.ts deleted file mode 100644 index d2e6c86..0000000 --- a/tests/tokenizer/tokenizer.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Replacer, replaceValues } from '../../src/tokenizer'; - -const EMPTY = ''; -const EMPTY_NEWLINES = '\n\n\n\n'; - -const SINGLE_VALUE = 'TEST=foo'; -const SINGLE_VALUE_RESULT = 'TEST=[REPLACED]'; - -const MULTIPLE_VALUES = `TEST=foo - -MEOW=bar -`; -const MULTIPLE_VALUES_RESULT = `TEST=[REPLACED] - -MEOW=[REPLACED] -`; - -const COMMENTS_ONLY = ` -# Comment -# TEST=entry -`; - -const MULTIPLE_VALUES_COMMENTS = `TEST=foo -# Meow Comment -FOO=barbarbar - -# MYTHICAL=QUEST - -# Grumpy -# Fox Jumps Over=The -ENV=kfope(!)#(@*)!)$_!@#!_@#)@_@!#::?/ -QUEUE=123 -`; - -const MULTIPLE_VALUES_COMMENTS_RESULT = `TEST=[REPLACED] -# Meow Comment -FOO=[REPLACED] - -# MYTHICAL=QUEST - -# Grumpy -# Fox Jumps Over=The -ENV=[REPLACED] -QUEUE=[REPLACED] -`; - -const replacer: Replacer = (match, key) => `${key}=[REPLACED]`; - -test('handle empty dotenv correctly', () => { - expect(replaceValues(EMPTY, replacer)).toEqual(EMPTY); -}); - -test('handle empty dotenv with newlines correctly', () => { - expect(replaceValues(EMPTY_NEWLINES, replacer)).toEqual(EMPTY_NEWLINES); -}); - -test('handle dotenv with only comments correctly', () => { - expect(replaceValues(COMMENTS_ONLY, replacer)).toEqual(COMMENTS_ONLY); -}); - -test('handle single value dotenv correctly', () => { - expect(replaceValues(SINGLE_VALUE, replacer)).toEqual(SINGLE_VALUE_RESULT); -}); - -test('handle multiple values dotenv correctly', () => { - expect(replaceValues(MULTIPLE_VALUES, replacer)).toEqual(MULTIPLE_VALUES_RESULT); -}); - -test('handle dotenv with multiple values and comments correctly', () => { - expect(replaceValues(MULTIPLE_VALUES_COMMENTS, replacer)) - .toEqual(MULTIPLE_VALUES_COMMENTS_RESULT); -}); From 3132b932f3735ad068ce89a9de56e15ab9afc893 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:14:17 +0300 Subject: [PATCH 07/19] Adds minimal README --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c610573 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# envienc + +Envienc is a command-line tool for encrypting dotenv and YAML files, while keeping keys, +comments, and overall structure untouched. + +It encrypts only the values, leaving the rest of the file intact. + +## Installation + +You need to have Node.js installed on your system. Then, run the following command: + +```bash +# Install envienc globally +npm install -g envienc + +# ... or use npx to run it without installing +npx envienc + +# ... or install it to your project and run it from there +cd your-project +npm install --save-dev envienc +npx envienc +``` + +## Quick start + +```bash +# First, let's init a new project. +# This will create a .enviencrc file in your project root. +# Using "-g" flag, you can specify globs for dotenv and YAML files. +npx envienc init -g ".env" -g ".env.*" -g "deployments/*.yml" + +# Then, add unencrypted files to .gitignore if applicable. +# This will prevent you from accidentally committing unencrypted files. +# Make sure that globs in .gitignore wouldn't match encrypted files with ".envienc" suffix + +# Now you can encrypt your files. +npx envienc encrypt + +# When you need to decrypt your files, run +npx envienc decrypt + +# Help is here anytime you need it +npx envienc --help +``` + +## Encryption + +Under the hood, envienc uses the AES-256-GCM algorithm to encrypt the values. + +- To produce the encryption key, it uses the PBKDF2 algorithm with 600,000 iterations. +- Salt for PBKDF2 is generated using Node's built-in CSPRNG via the `crypto.randomBytes()` method. + Salt is unique per project and stored in `.enviencrc` configuration file. +- Each encrypted value has its own unique IV and auth tag stored with ciphertext. + +## Password input + +You can provide the password in several ways. The order of precedence is as follows: + +1. Using the `--password` option. Please, don't ever do this in production. +2. Using the `ENVIENC_PWD` environment variable. +3. Using the interactive prompt when encrypting or decrypting. From 39edb3a663dbc57b126587988e054102577db465 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:16:24 +0300 Subject: [PATCH 08/19] Create CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..53e70ea --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +Dmitry Nourell . +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. From 7d17e49e376fa620f9ab44a84548f4fbfaebf3ac Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:17:14 +0300 Subject: [PATCH 09/19] Create LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d1b0e73 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Dmitry Nourell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 0202ece681e629ca13a54c613a7c0cae948b8f8d Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:22:16 +0300 Subject: [PATCH 10/19] Create SECURITY.md --- SECURITY.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e8e6340 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 2.0.x | :white_check_mark: | +| < 2.0 | :x: | + +## Reporting a Vulnerability + +Please reach me via email + PGP using public key below. Email address is embedded into the public key: +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBF0AFM8BDAC9WlqRqaVBXkLYGWK56QQQvq3It4v7cB5OkBmEkIDkWG6+YNjW +WM0THb2aqZ34fLhbVIcNxumpnldy7K5B0X4s907OxzfUypeepFIoTubCY2gQLa4Y +rMXyMOdNCIbvYRvDyOE9qHvWFxJTluSlcx9S9FN1tE/T6SFlupk35BvBBIt1zGLf +5L4DUJuDyskzXcMXjIVyv6f6yZzdiO1r5hKTaBX7EtnCpdQ4CyLOFyUfkLEkBh/r +QO9EvnR2ggyDyJH65vVs2inImbCnGjEcuL3YXNtHcnWKj84ZdGgIn05UOv11abt+ +GWbaEIB37j1HZ8fVgwnY7RkT9H1avFyYMbBjRDSjtuSGaRmndgiJ0IUepnMKHdQh +8u9EtU/sls4e26Jst4AwzeEzX2wN0/guW8C6GsaTcHjI5pt1FnL9QZNjQHkRsZ9v +Xmd3EmZr+vURlHd4l050CFfVLUxiOPJpd3vRPCPXPdSyFIEibVzFn7yQ+v0i43gz +8WHvFR0Ptfp9MNMAEQEAAbQoRG1pdHJ5IFBlcHBlcnN0ZWluIDxoaUBpbWNhdHdo +b2NvZGUuZGV2PokBzgQTAQoAOBYhBBA3UdzMp05kMoBbve2vV8pGAls1BQJdABTP +AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEO2vV8pGAls1ZigMAJ+sE0yh +dSn3kUztyEIDa7EUm+8KcybC9sUG/wbwY/B74/FXqHpWfCtUIHBGi//GhN+rZEz3 +VN7IWiMBctnUyzmvFtftq1D7f2ghY6hQSgeC4FGgW23ad3ilBK+4A8HTvAbW6sgM +Xg/Tt/TI3s2XBOaiXaQPsEoqaLJ/wdWQbcGHzchGov4e6qkh79vJAbCLu5tb1a3Z +LFnxTP7rU9nfImHt6GG/r3L3FGPdhNHHiTKApAWFkiYIrgzsUzMkA62lqwUmmkJi +cSKM9OzfeT4KqN//faJeIhAuDk2QlYAyMZ1NpRNRrhXnyU2qozq8hrAEN1NqFUEi +BTiKj7maicxHiTkSmhhh3JGY/Mb8WRuSRYGVx9I9DFbXY+Qpg+ir1rQiwBa3RwDV +MjWjDXFKk+2q7q312bGu6E1B2lRhhG6Jj9M6LwOdidjzmZRxUstQFUwGIg0tsjew +wjW0ulS5kJSEQ+seEiIVtksaO6DOSpBdJFs5EjU103C44tUU3NAEi5zkHLkBjQRd +ABTPAQwA0yovfS76jy4QAxxLVTPol3FsdpQ+JrFYnhx1EeonUFeeoUwwGW01JhtJ +bCnfqTVw3c9aQj08pq4VSvuRnfBoUg7as92anRtPZeG3FkDeFv9f3RzGICPWw7QP +xUEHJooDhws2hrEUiBXp0JmHGHGk+E/WxSts4ZB1nXpwl6wQVCAWxxjR2u6nHYCx +Ald+LY2euE//2EwikSslDHJL9DaQaGxImZrF41xQWfqcp5ahLTAV7dgrcSuGjQqI +PaV67mwfkSdsMnK5a1YoQ325yZannHxttZxH0+g73kFWxYiThJu6h5ECRVSlS/TN +thwsfNwdMUfRjDQNl5uRdUr4/0iqxGh/e9qMRn7srrNK4mfnBQDGSTeCdAEHDqjJ +VbqsxF9/niIiLJtp5ot8rxDpvQLiAcXymEPjPex5nF4Kmcrg4mAZ4g38oaf8A6IQ +bMVI5I3bJyR+8iPTY3D4yvDT4NX2NEEG37UiXGKZxSG5xzngO8lkXpS9q3MKpgEb +N4asooZRABEBAAGJAbYEGAEKACAWIQQQN1HczKdOZDKAW73tr1fKRgJbNQUCXQAU +zwIbDAAKCRDtr1fKRgJbNZ/JC/9GUaY+RJqYcCtW+OVsYYNBxfeU73N5HlNjO8KB +tKu7zT7xjSVDWQVTLe85+fhAJ4QuA8RiZDwsvGdVaCR7vzrxZknHPM9CVJjbSjtM +llmr0L9TQ0sd7We15hrlZ30StSwBFl7TWkVGcZAWSRrFc/n7KJlbqzKLljMfYjKQ ++EftLlwAO/SQb53Dq1Wv2UB+X6oPlVyq5z6LoY/WGDcLIAENlkVrVukh3KJS4i64 +5Ag4QaZOtzQlehh72s0VdAWX7MHDfoxvhXI9SDo1f9IZI1ieK8+bOjMSUbXJAMAX +erwhXaz4HlawNfN5yhgt2rwDw9BT4NgzxNB+UVRqIUYUiXgB6jVhnX29ICwrj+Kw +xvmUN0mXknCRMx9VagEsowMdOOdO6+guqrSCPl5GbB3WJw76bGxcoG0pkxdpPEAC +u2PBR0zNxarRAAVdgVjc6nDNDFbTo8zXY1ZhRLeMz+x9nLiytxhr8onWNW3xN6rV +yIvGSJmH03WF0ibdNfncprb2VDo= +=SLis +-----END PGP PUBLIC KEY BLOCK----- +``` + +Alternatively, use Keybase: https://keybase.io/imcatwhocode From aaa84f23af94ed3b4631fd046464642458633fa8 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:31:59 +0300 Subject: [PATCH 11/19] Few more touches on README --- README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c610573..d8cf24a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://stand-with-ukraine.pp.ua) + # envienc Envienc is a command-line tool for encrypting dotenv and YAML files, while keeping keys, @@ -7,7 +9,7 @@ It encrypts only the values, leaving the rest of the file intact. ## Installation -You need to have Node.js installed on your system. Then, run the following command: +You need to have Node.js installed on your system. Then, run the following commands: ```bash # Install envienc globally @@ -44,6 +46,61 @@ npx envienc decrypt npx envienc --help ``` +## Exceptions + +You can skip specific configuration entries from being encrypted. + +Use `@envienc no-encrypt` comment: + +## For dotenv + +```dotenv +# @envienc no-encrypt +PUBLIC_INFO=This variable wouldn't be encrypted + +# But this one would +MY_SECRET=hellokitty +``` + +## For YAML + +```yaml +nested: + - item1: + # Flag below would prevent encryption of entire "item1" entry + # @envienc no-encrypt + key1: value1 + key2: value2 + subitems: + - subitem1 + - subitem2 + # "item2" will be encrypted as expected + - item2: + keyA: valueA + keyB: valueB + +colors: + red: "#FF0000" + green: "#00FF00" + # Flag below would prevent encryption only of "blue" entry + blue: "#0000FF" # @envienc no-encrypt + random: + rgb: [ + 128, + # Flag below would prevent encryption only of "255" value + 255, # @envienc no-encrypt + 64, + ] + hex: "#FFFFFF" + +# Entire "branding" entry will be kept unencrypted +# @envienc no-encrypt +branding: + logo_uri: "https://example.com/logo.png" + name: "My App" + description: "My App is a great app" +``` + ## Encryption Under the hood, envienc uses the AES-256-GCM algorithm to encrypt the values. From 953e6d32ba0233fe572fb0f19decbbfeb2213444 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:54:19 +0300 Subject: [PATCH 12/19] Adds unit tests & publish workflows for Actions --- .github/workflows/release.yml | 59 ++++++++++++++++++++++++++++++++ .github/workflows/unit-tests.yml | 47 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..29b01c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release Workflow + +on: + push: + tags: + - "v*" + branches: + - release + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: "18.x" + + - name: Install Dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Get Previous Release Tag + id: get_previous_release_tag + uses: actions/github-script@v4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: releases } = await github.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1, + }); + return releases[0].tag_name; + + - name: Create Release + id: create_release + uses: actions/create-release@v1.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + body: | + Changes in this Release: + $(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 ${{ steps.get_previous_release_tag.outputs.result }})..HEAD) + draft: false + prerelease: false + + - name: Publish to NPM + uses: js-actions/npm-publish@v1.0.0 + with: + token: ${{ secrets.NPM_TOKEN }} + access: "public" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..739643a --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,47 @@ +name: Run Tests on PR to main + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: "18.x" + + - name: Install Dependencies + run: npm ci + + - name: Verify Package Version + run: | + tag_version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') + package_version=$(node -p "require('./package.json').version") + if [ "$tag_version" != "$package_version" ]; then + echo "Error: package.json version ($package_version) does not match tag version ($tag_version)" + exit 1 + fi + + - name: Verify Package Lock Version + run: | + tag_version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') + lockfile_version=$(node -p "require('./package-lock.json').version") + if [ "$tag_version" != "$lockfile_version" ]; then + echo "Error: package-lock.json version ($lockfile_version) does not match tag version ($tag_version)" + exit 1 + fi + + - name: Run Tests with Coverage + run: npm run test:coverage + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v2.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} From d9b3734df1f1daacb72b0315fad6080c420944a2 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:55:15 +0300 Subject: [PATCH 13/19] Remove excess "main" branch in favor to "release" --- .github/workflows/unit-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 739643a..3a8fbf4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,9 +1,9 @@ -name: Run Tests on PR to main +name: Run Tests on PR to release on: pull_request: branches: - - main + - release jobs: test: From 30bfd2344a7a6eeb358a7f292e20a28fe3bd2315 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 06:57:38 +0300 Subject: [PATCH 14/19] Move package versions check to Release workflow --- .github/workflows/release.yml | 18 ++++++++++++++++++ .github/workflows/unit-tests.yml | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29b01c5..805f1f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,24 @@ jobs: - name: Install Dependencies run: npm ci + - name: Verify Package Version + run: | + tag_version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') + package_version=$(node -p "require('./package.json').version") + if [ "$tag_version" != "$package_version" ]; then + echo "Error: package.json version ($package_version) does not match tag version ($tag_version)" + exit 1 + fi + + - name: Verify Package Lock Version + run: | + tag_version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') + lockfile_version=$(node -p "require('./package-lock.json').version") + if [ "$tag_version" != "$lockfile_version" ]; then + echo "Error: package-lock.json version ($lockfile_version) does not match tag version ($tag_version)" + exit 1 + fi + - name: Build run: npm run build diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3a8fbf4..e7f747b 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,24 +20,6 @@ jobs: - name: Install Dependencies run: npm ci - - name: Verify Package Version - run: | - tag_version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') - package_version=$(node -p "require('./package.json').version") - if [ "$tag_version" != "$package_version" ]; then - echo "Error: package.json version ($package_version) does not match tag version ($tag_version)" - exit 1 - fi - - - name: Verify Package Lock Version - run: | - tag_version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') - lockfile_version=$(node -p "require('./package-lock.json').version") - if [ "$tag_version" != "$lockfile_version" ]; then - echo "Error: package-lock.json version ($lockfile_version) does not match tag version ($tag_version)" - exit 1 - fi - - name: Run Tests with Coverage run: npm run test:coverage From b9ba02f3d6f837915e1ac86cdcf67b1b555d0b24 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 07:04:59 +0300 Subject: [PATCH 15/19] Update tests to fail on any TS build error --- package.json | 4 ++-- src/flags.ts | 4 ++-- tsconfig.json | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index aa5454d..78e8652 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "build": "tsc && node ./scripts/build-shebang.js", "build:watch": "tsc --watch", "lint": "eslint src tests", - "test": "jest --verbose", - "test:coverage": "jest --coverage --collectCoverageFrom=src/**/*.ts" + "test": "tsc --noEmit && jest --verbose", + "test:coverage": "tsc --noEmit && jest --coverage --collectCoverageFrom=src/**/*.ts" }, "devDependencies": { "@types/jest": "^29.4.0", diff --git a/src/flags.ts b/src/flags.ts index c407bfb..0e7f66a 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -12,8 +12,8 @@ export function parseCommentFlags( const opts: ParserCommentOpts = {}; comments - .filter(a => typeof a === 'string' && a.length) - .map(a => a.trim()) + .filter(a => typeof a === 'string' && a.length > 0) + .map(a => (a as string).trim()) .filter(a => a.startsWith('@envienc')) .forEach(entry => { const ruleset = entry.split(' ').slice(1); diff --git a/tsconfig.json b/tsconfig.json index ade17b4..d8dc015 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": false, - "sourceMap": false + "sourceMap": false, + "noEmitOnError": true }, "files": ["src/index.ts"] } From cf48e06540ff574dc40f4fa5f84be9fb0013f698 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 07:12:01 +0300 Subject: [PATCH 16/19] Fix Actions workflows --- .github/workflows/release.yml | 9 +++------ .github/workflows/unit-tests.yml | 6 +++--- package.json | 4 ++-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 805f1f3..16700c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,12 +58,9 @@ jobs: - name: Create Release id: create_release - uses: actions/create-release@v1.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: ncipollo/release-action@v1 with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} + name: Release ${{ github.ref }} body: | Changes in this Release: $(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 ${{ steps.get_previous_release_tag.outputs.result }})..HEAD) @@ -71,7 +68,7 @@ jobs: prerelease: false - name: Publish to NPM - uses: js-actions/npm-publish@v1.0.0 + uses: js-actions/npm-publish@v2 with: token: ${{ secrets.NPM_TOKEN }} access: "public" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e7f747b..593996c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: "18.x" @@ -24,6 +24,6 @@ jobs: run: npm run test:coverage - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2.1.0 + uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/package.json b/package.json index 78e8652..aa5454d 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "build": "tsc && node ./scripts/build-shebang.js", "build:watch": "tsc --watch", "lint": "eslint src tests", - "test": "tsc --noEmit && jest --verbose", - "test:coverage": "tsc --noEmit && jest --coverage --collectCoverageFrom=src/**/*.ts" + "test": "jest --verbose", + "test:coverage": "jest --coverage --collectCoverageFrom=src/**/*.ts" }, "devDependencies": { "@types/jest": "^29.4.0", From c73756b4bc28b01227130794880fd8ba6ff78622 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 07:17:47 +0300 Subject: [PATCH 17/19] Refine CI Actions versions --- .github/workflows/release.yml | 6 +++--- .github/workflows/unit-tests.yml | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16700c3..400a0e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "18.x" @@ -45,7 +45,7 @@ jobs: - name: Get Previous Release Tag id: get_previous_release_tag - uses: actions/github-script@v4 + uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 593996c..b612eb8 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -13,13 +13,16 @@ jobs: - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v3 with: node-version: "18.x" - name: Install Dependencies run: npm ci + - name: Lint + run: npm run lint + - name: Run Tests with Coverage run: npm run test:coverage From 50faf70b9de0d50193820d3403e1aa4a52a30bc2 Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 07:34:07 +0300 Subject: [PATCH 18/19] Resolve Jest & TS issue with noEmitOnError --- jest.config.js | 4 +++- tsconfig.jest.json | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 tsconfig.jest.json diff --git a/jest.config.js b/jest.config.js index 994a9a5..8f64af9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,8 @@ module.exports = { '**/?(*.)+(spec|test).+(ts|tsx|js)', ], transform: { - '^.+\\.(ts|tsx)$': 'ts-jest', + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: 'tsconfig.jest.json', + }], }, }; diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..d70e282 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["tests/**/*.ts"], + "compilerOptions": { + "noEmitOnError": false + } +} From ca3b0ee89548ec9073b44b5f0164417d03779b7a Mon Sep 17 00:00:00 2001 From: Dmitry Nourell Date: Fri, 13 Oct 2023 07:35:21 +0300 Subject: [PATCH 19/19] Adjust compilation error check in Workflow --- .github/workflows/unit-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b612eb8..a6fad45 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,8 +20,8 @@ jobs: - name: Install Dependencies run: npm ci - - name: Lint - run: npm run lint + - name: Check compilation and linting errors + run: npx tsc --noEmit && npm run lint - name: Run Tests with Coverage run: npm run test:coverage