yamlock 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/cli/cli.js +66 -27
- package/dist/utils/config.js +20 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,9 @@ yamlock algorithms
|
|
|
60
60
|
|
|
61
61
|
# Generate a random key for YAMLOCK_KEY
|
|
62
62
|
yamlock keygen --length 64 --format base64
|
|
63
|
+
|
|
64
|
+
# Preview changes without touching files
|
|
65
|
+
yamlock encrypt config.yml -o config.enc.yml -p db.password -k "my-secret-key" -d
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
The CLI detects YAML (`.yaml`/`.yml`) and JSON extensions automatically and writes the file back in the same format.
|
|
@@ -67,6 +70,7 @@ The CLI detects YAML (`.yaml`/`.yml`) and JSON extensions automatically and writ
|
|
|
67
70
|
Options of note:
|
|
68
71
|
- `--output <file>` writes the result to a separate file instead of overwriting the input.
|
|
69
72
|
- `--paths <path1,path2>` targets only the specified fields (dot/bracket notation like `db.password` or `users[0].token`).
|
|
73
|
+
- `--dry-run` shows the would-be changes without modifying files (prints original vs new content).
|
|
70
74
|
- Command `keygen` produces a random key and shows how to store it (shell export or `.env`).
|
|
71
75
|
- Command `algorithms` prints two lists: tested presets (covered by yamlock) and additional ciphers available from the runtime.
|
|
72
76
|
- Command `version` prints the installed CLI version.
|
|
@@ -116,11 +120,36 @@ const restored = processConfig(processed, {
|
|
|
116
120
|
key: KEY,
|
|
117
121
|
algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
|
|
118
122
|
});
|
|
123
|
+
|
|
124
|
+
// Control what happens when encountering non-string values and customize path IDs
|
|
125
|
+
const mixedConfig = { db: { password: 'secret', retries: 3 } };
|
|
126
|
+
const lockedMixed = processConfig(mixedConfig, {
|
|
127
|
+
mode: 'encrypt',
|
|
128
|
+
key: KEY,
|
|
129
|
+
nonStringPolicy: 'stringify', // stringifies numbers/objects before encrypting
|
|
130
|
+
pathSerializer: (segments) => segments.join('/') // custom path naming (db/password instead of dot notation)
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Example of a path serializer that includes array indexes explicitly
|
|
134
|
+
const lockedUsers = processConfig(
|
|
135
|
+
{ users: [{ tokens: ['abc'] }] },
|
|
136
|
+
{
|
|
137
|
+
mode: 'encrypt',
|
|
138
|
+
key: KEY,
|
|
139
|
+
pathSerializer: (segments) =>
|
|
140
|
+
segments
|
|
141
|
+
.map((segment, index) =>
|
|
142
|
+
typeof segment === 'number' ? `[${segment}]` : index === 0 ? segment : `/${segment}`
|
|
143
|
+
)
|
|
144
|
+
.join('')
|
|
145
|
+
}
|
|
146
|
+
);
|
|
119
147
|
```
|
|
120
148
|
|
|
121
149
|
## Advanced usage
|
|
122
150
|
|
|
123
151
|
- **Selective encryption**: combine `--paths` on the CLI or `paths: []` in `processConfig` to encrypt only sensitive sections of a config file.
|
|
152
|
+
- **Non-string handling**: use `nonStringPolicy: 'ignore' | 'stringify' | 'error'` to control how numbers/objects are treated, and `pathSerializer` to change how traversal paths are represented (e.g., `db/password` instead of dot notation).
|
|
124
153
|
- **CI/CD flows**: see [examples/docs/ci-cd.md](examples/docs/ci-cd.md) for a GitHub Actions job that decrypts configs for builds and re-encrypts them before publishing artifacts.
|
|
125
154
|
- **Key rotation**: follow [examples/docs/key-rotation.md](examples/docs/key-rotation.md) for a step-by-step process, including scripting tips for large repos.
|
|
126
155
|
|
package/dist/cli/cli.js
CHANGED
|
@@ -37,6 +37,7 @@ Options:
|
|
|
37
37
|
-a, --algorithm <value> Cipher algorithm (default: aes-256-cbc).
|
|
38
38
|
-o, --output <file> Write the result to a different file (otherwise overwrites the input file).
|
|
39
39
|
-p, --paths <p1,p2> Comma-separated list of field paths to process (dot/bracket notation).
|
|
40
|
+
-d, --dry-run Show the diff without modifying files.
|
|
40
41
|
--length <bytes> (keygen) Number of random bytes to generate (default: 32).
|
|
41
42
|
--format <hex|base64> (keygen) Output format (default: base64).
|
|
42
43
|
`;
|
|
@@ -64,21 +65,22 @@ function readConfigFile(filePath) {
|
|
|
64
65
|
const format = detectFormat(filePath);
|
|
65
66
|
|
|
66
67
|
if (format === 'yaml') {
|
|
67
|
-
return { format, data: yaml.load(content) ?? {} };
|
|
68
|
+
return { format, data: yaml.load(content) ?? {}, raw: content };
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
return { format, data: JSON.parse(content) };
|
|
71
|
+
return { format, data: JSON.parse(content), raw: content };
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
function
|
|
74
|
+
function serializeConfig(format, data) {
|
|
74
75
|
if (format === 'yaml') {
|
|
75
|
-
|
|
76
|
-
writeFileSync(filePath, serialized, 'utf8');
|
|
77
|
-
return;
|
|
76
|
+
return yaml.dump(data, { lineWidth: 120 });
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeConfigFile(filePath, serialized) {
|
|
83
|
+
writeFileSync(filePath, serialized, 'utf8');
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function parsePaths(value) {
|
|
@@ -97,7 +99,7 @@ function parseArgs(argv) {
|
|
|
97
99
|
const result = {
|
|
98
100
|
command: args[0],
|
|
99
101
|
file: undefined,
|
|
100
|
-
options: {}
|
|
102
|
+
options: { dryRun: false }
|
|
101
103
|
};
|
|
102
104
|
|
|
103
105
|
let index = 1;
|
|
@@ -131,9 +133,15 @@ function parseArgs(argv) {
|
|
|
131
133
|
} else if (arg === '--format') {
|
|
132
134
|
result.options.format = next;
|
|
133
135
|
i += 1;
|
|
136
|
+
} else if (arg === '-d' || arg === '--dry-run') {
|
|
137
|
+
result.options.dryRun = true;
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
if (result.options.dryRun && result.command && !result.file && !['version', 'algorithms', 'keygen'].includes(result.command)) {
|
|
142
|
+
// dry-run without file is invalid, but we'll let later validation handle file requirement
|
|
143
|
+
}
|
|
144
|
+
|
|
137
145
|
return result;
|
|
138
146
|
}
|
|
139
147
|
|
|
@@ -146,6 +154,30 @@ function generateRandomKey(length, format) {
|
|
|
146
154
|
return buffer.toString('base64');
|
|
147
155
|
}
|
|
148
156
|
|
|
157
|
+
function fail(code, message) {
|
|
158
|
+
printError(`[yamlock:${code}] ${message}`);
|
|
159
|
+
return exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleWrite({ dryRun, file, outputPath, format, originalRaw, data, operation }) {
|
|
163
|
+
const serialized = serializeConfig(format, data);
|
|
164
|
+
if (dryRun) {
|
|
165
|
+
print(`DRY-RUN (${operation}) ${file}`);
|
|
166
|
+
print('--- original');
|
|
167
|
+
print((originalRaw ?? '').trimEnd());
|
|
168
|
+
print('+++ result');
|
|
169
|
+
print(serialized.trimEnd());
|
|
170
|
+
if (outputPath !== file) {
|
|
171
|
+
print(`(would write to ${outputPath})`);
|
|
172
|
+
}
|
|
173
|
+
print('No files were modified.');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
writeConfigFile(outputPath, serialized);
|
|
178
|
+
print(`${operation === 'encrypt' ? 'Encrypted' : 'Decrypted'} values in ${outputPath}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
export async function runCli(argv = process.argv) {
|
|
150
182
|
const { command, file, options } = parseArgs(argv);
|
|
151
183
|
|
|
@@ -181,13 +213,11 @@ export async function runCli(argv = process.argv) {
|
|
|
181
213
|
const normalizedFormat = (options.format ?? 'base64').toLowerCase();
|
|
182
214
|
|
|
183
215
|
if (!Number.isFinite(desiredLength) || desiredLength <= 0) {
|
|
184
|
-
|
|
185
|
-
return exit(1);
|
|
216
|
+
return fail('ERR_INVALID_LENGTH', 'Key length must be a positive number.');
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
if (!['base64', 'hex'].includes(normalizedFormat)) {
|
|
189
|
-
|
|
190
|
-
return exit(1);
|
|
220
|
+
return fail('ERR_INVALID_FORMAT', 'Key format must be either "base64" or "hex".');
|
|
191
221
|
}
|
|
192
222
|
|
|
193
223
|
const keyValue = generateRandomKey(desiredLength, normalizedFormat);
|
|
@@ -200,15 +230,13 @@ export async function runCli(argv = process.argv) {
|
|
|
200
230
|
}
|
|
201
231
|
|
|
202
232
|
if (!file) {
|
|
203
|
-
printError('A file path is required for this command.');
|
|
204
233
|
print(getHelpText().trim());
|
|
205
|
-
return
|
|
234
|
+
return fail('ERR_FILE_REQUIRED', 'A file path is required for this command.');
|
|
206
235
|
}
|
|
207
236
|
|
|
208
237
|
const key = options.key ?? process.env.YAMLOCK_KEY;
|
|
209
238
|
if (!key) {
|
|
210
|
-
|
|
211
|
-
return exit(1);
|
|
239
|
+
return fail('ERR_MISSING_KEY', 'Encryption key is required via --key or YAMLOCK_KEY.');
|
|
212
240
|
}
|
|
213
241
|
|
|
214
242
|
const absolutePath = resolve(process.cwd(), file);
|
|
@@ -216,8 +244,7 @@ export async function runCli(argv = process.argv) {
|
|
|
216
244
|
try {
|
|
217
245
|
config = readConfigFile(absolutePath);
|
|
218
246
|
} catch (error) {
|
|
219
|
-
|
|
220
|
-
return exit(1);
|
|
247
|
+
return fail('ERR_READ_FAILED', `Failed to read config file: ${error.message}`);
|
|
221
248
|
}
|
|
222
249
|
|
|
223
250
|
const outputPath = options.output
|
|
@@ -232,8 +259,15 @@ export async function runCli(argv = process.argv) {
|
|
|
232
259
|
algorithm: options.algorithm,
|
|
233
260
|
paths: options.paths
|
|
234
261
|
});
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
handleWrite({
|
|
263
|
+
dryRun: options.dryRun,
|
|
264
|
+
file,
|
|
265
|
+
outputPath,
|
|
266
|
+
format: config.format,
|
|
267
|
+
originalRaw: config.raw,
|
|
268
|
+
data: result,
|
|
269
|
+
operation: 'encrypt'
|
|
270
|
+
});
|
|
237
271
|
return exit(0);
|
|
238
272
|
}
|
|
239
273
|
|
|
@@ -244,17 +278,22 @@ export async function runCli(argv = process.argv) {
|
|
|
244
278
|
algorithm: options.algorithm,
|
|
245
279
|
paths: options.paths
|
|
246
280
|
});
|
|
247
|
-
|
|
248
|
-
|
|
281
|
+
handleWrite({
|
|
282
|
+
dryRun: options.dryRun,
|
|
283
|
+
file,
|
|
284
|
+
outputPath,
|
|
285
|
+
format: config.format,
|
|
286
|
+
originalRaw: config.raw,
|
|
287
|
+
data: result,
|
|
288
|
+
operation: 'decrypt'
|
|
289
|
+
});
|
|
249
290
|
return exit(0);
|
|
250
291
|
}
|
|
251
292
|
|
|
252
|
-
printError(`Unknown command: ${command}`);
|
|
253
293
|
print(getHelpText().trim());
|
|
254
|
-
return
|
|
294
|
+
return fail('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
|
|
255
295
|
} catch (error) {
|
|
256
|
-
|
|
257
|
-
return exit(1);
|
|
296
|
+
return fail('ERR_PROCESS_FAILED', `Operation failed: ${error.message}`);
|
|
258
297
|
}
|
|
259
298
|
}
|
|
260
299
|
|
package/dist/utils/config.js
CHANGED
|
@@ -15,6 +15,8 @@ const MODES = {
|
|
|
15
15
|
* @param {string|Buffer} options.key
|
|
16
16
|
* @param {string|object} [options.algorithm]
|
|
17
17
|
* @param {object} [options.algorithmOptions]
|
|
18
|
+
* @param {"ignore"|"stringify"|"error"} [options.nonStringPolicy]
|
|
19
|
+
* @param {(segments: Array<string|number>) => string} [options.pathSerializer]
|
|
18
20
|
* @param {string[]} [options.paths]
|
|
19
21
|
* @param {Array<string|number>} [options.parentPath]
|
|
20
22
|
* @returns {Object|Array}
|
|
@@ -37,11 +39,13 @@ export function processConfig(node, options) {
|
|
|
37
39
|
...options,
|
|
38
40
|
mode,
|
|
39
41
|
parentPath: options.parentPath ?? [],
|
|
40
|
-
normalizedPaths
|
|
42
|
+
normalizedPaths,
|
|
43
|
+
nonStringPolicy: options.nonStringPolicy ?? 'ignore',
|
|
44
|
+
pathSerializer: options.pathSerializer
|
|
41
45
|
});
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function traverseConfig(node, { mode, key, algorithm, algorithmOptions, parentPath, normalizedPaths }) {
|
|
48
|
+
function traverseConfig(node, { mode, key, algorithm, algorithmOptions, parentPath, normalizedPaths, nonStringPolicy, pathSerializer }) {
|
|
45
49
|
const isArrayNode = Array.isArray(node);
|
|
46
50
|
const result = isArrayNode ? [] : {};
|
|
47
51
|
const cryptoOptions = algorithmOptions ?? algorithm;
|
|
@@ -49,7 +53,9 @@ function traverseConfig(node, { mode, key, algorithm, algorithmOptions, parentPa
|
|
|
49
53
|
Object.entries(node).forEach(([rawKey, value]) => {
|
|
50
54
|
const segment = isArrayNode ? Number(rawKey) : rawKey;
|
|
51
55
|
const targetKey = isArrayNode ? segment : rawKey;
|
|
52
|
-
const currentPath =
|
|
56
|
+
const currentPath = pathSerializer
|
|
57
|
+
? pathSerializer([...parentPath, segment])
|
|
58
|
+
: buildPath(parentPath, segment);
|
|
53
59
|
|
|
54
60
|
if (value !== null && typeof value === 'object') {
|
|
55
61
|
result[targetKey] = traverseConfig(value, {
|
|
@@ -58,14 +64,22 @@ function traverseConfig(node, { mode, key, algorithm, algorithmOptions, parentPa
|
|
|
58
64
|
algorithm: cryptoOptions,
|
|
59
65
|
algorithmOptions: cryptoOptions,
|
|
60
66
|
parentPath: [...parentPath, segment],
|
|
61
|
-
normalizedPaths
|
|
67
|
+
normalizedPaths,
|
|
68
|
+
nonStringPolicy,
|
|
69
|
+
pathSerializer
|
|
62
70
|
});
|
|
63
71
|
return;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
if (typeof value !== 'string') {
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
if (nonStringPolicy === 'stringify') {
|
|
76
|
+
value = JSON.stringify(value);
|
|
77
|
+
} else if (nonStringPolicy === 'error') {
|
|
78
|
+
throw new Error(`Non-string value encountered at ${currentPath}`);
|
|
79
|
+
} else {
|
|
80
|
+
result[targetKey] = value;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
const shouldProcess = !normalizedPaths || normalizedPaths.has(currentPath);
|
package/package.json
CHANGED