@jondotsoy/envs 0.0.4 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/envs.js +275 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,11 +25,28 @@ Ejecuta los comandos dentro del repo o de cualquiera de sus worktrees. Dentro de
25
25
  | `envs init` | Crea `.envs/`, `.envs/.gitignore` (con `*`) y un `.envs/values.yml` vacío. No sobrescribe archivos existentes. |
26
26
  | `envs pull` | Lee el `.env` del repo principal y de cada worktree y lo escribe en `values.yml`. Las variables que ya no están en un `.env` se quitan de `values.yml` para ese worktree. |
27
27
  | `envs push` | Escribe `values.yml` en el `.env` de cada worktree. Actualiza las variables existentes en su línea, agrega las nuevas al final y conserva comentarios y variables que no están en `values.yml`. |
28
+ | `envs lint` | Revisa la seguridad y muestra un warning por cada problema. Termina con código 1 si hay alguno (útil en CI). Ver abajo. |
28
29
  | `envs edit` | Ejecuta `pull`, abre `values.yml` con `code -w` y, al cerrar el editor, ejecuta `push`. Si el editor falla, no hace `push`. |
29
30
  | `envs help` | Muestra la ayuda. |
30
31
 
31
32
  Los worktrees se identifican por el nombre de su rama (o el nombre de su carpeta si están en HEAD desacoplado). El repo principal usa el nombre de su rama, por ejemplo `main`.
32
33
 
34
+ ### `envs lint`
35
+
36
+ Nunca imprime los valores, solo la variable y su ubicación (`envs.DB_PASSWORD.main`).
37
+
38
+ | Regla | Detecta |
39
+ | --- | --- |
40
+ | `tracked-values`, `unignored-values` | `values.yml` está versionado en git o no está ignorado. |
41
+ | `tracked-dotenv`, `unignored-dotenv` | El `.env` de un worktree está versionado o no está ignorado. |
42
+ | `open-permissions` | `values.yml` es legible por otros usuarios (usa `chmod 600`). |
43
+ | `executable-files` | `values.yml` o un `.env` tiene permiso de ejecución (usa `chmod -x`). |
44
+ | `writable-files` | `values.yml` o un `.env` es escribible por el grupo u otros usuarios (usa `chmod go-w`). |
45
+ | `weak-secret` | Variable sensible (`PASSWORD`, `TOKEN`, `SECRET`, `API_KEY`…) vacía o con un valor típico (`changeme`, `admin`…). |
46
+ | `shared-secret` | Variable sensible en `defaults`, que se escribe en todos los worktrees. |
47
+ | `secret-pattern` | Valor con formato de secreto conocido (AWS, GitHub, Slack, `sk-…`, clave privada). |
48
+ | `url-credentials`, `insecure-url` | URL remota con contraseña embebida, o con `http://`, `ws://` o `ftp://`. |
49
+
33
50
  ## Formato de `values.yml`
34
51
 
35
52
  ```yaml
@@ -47,7 +64,7 @@ envs:
47
64
 
48
65
  - `defaults`: valor por defecto de cada variable. `push` lo escribe en el `.env` de todos los worktrees.
49
66
  - `envs.<VARIABLE>.<worktree>`: valor de la variable en ese worktree. Sobrescribe al valor de `defaults`.
50
- - Todos los valores son strings, así que los números van entre comillas (`"3000"`).
67
+ - Los valores pueden ser strings, números o booleanos (`PORT: 3000`, `DEBUG: true`); `push` los escribe en el `.env` como texto (`PORT=3000`, `DEBUG=true`). `pull` convierte `true`/`false` y los números canónicos (`3000`, `0.5`) a booleanos y números; el resto (`007`, `1e3`) queda como string.
51
68
 
52
69
  El schema (JSON Schema) está en [`schema/values.schema.json`](schema/values.schema.json). Para que VS Code valide el archivo con la extensión YAML, agrega al inicio de `values.yml`:
53
70
 
package/envs.js CHANGED
@@ -4,13 +4,228 @@
4
4
  // src/envs.ts
5
5
  import { mkdir } from "fs/promises";
6
6
  import { basename, dirname, join } from "path";
7
+ import { stat } from "fs/promises";
7
8
  var {$ } = globalThis.Bun;
9
+
10
+ // src/lint-rules.ts
11
+ var SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
12
+ var WEAK_VALUES = new Set([
13
+ "",
14
+ "changeme",
15
+ "change-me",
16
+ "change_me",
17
+ "password",
18
+ "passw0rd",
19
+ "secret",
20
+ "admin",
21
+ "root",
22
+ "test",
23
+ "default",
24
+ "example",
25
+ "123456",
26
+ "12345678",
27
+ "qwerty",
28
+ "todo",
29
+ "xxx"
30
+ ]);
31
+ var SECRET_PATTERNS = [
32
+ [/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
33
+ [/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
34
+ [/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
35
+ [/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
36
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
37
+ ];
38
+ function isSensitiveKey(key) {
39
+ return SENSITIVE_KEY.test(key);
40
+ }
41
+ function entries({ values }) {
42
+ const result = [];
43
+ for (const [key, value] of Object.entries(values.defaults ?? {})) {
44
+ result.push({ key, text: String(value), location: `defaults.${key}`, shared: true });
45
+ }
46
+ for (const [key, byWorktree] of Object.entries(values.envs ?? {})) {
47
+ for (const [worktree, value] of Object.entries(byWorktree ?? {})) {
48
+ result.push({
49
+ key,
50
+ text: String(value),
51
+ location: `envs.${key}.${worktree}`,
52
+ shared: false
53
+ });
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+ function parseUrl(text) {
59
+ const match = text.match(/^([a-z][a-z0-9+.-]*):\/\/([^/\s]*)/i);
60
+ if (!match)
61
+ return;
62
+ const scheme = match[1].toLowerCase();
63
+ const authority = match[2];
64
+ const hostname = authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
65
+ return {
66
+ scheme,
67
+ hasPassword: /^[^@]*:[^@]+@/.test(authority),
68
+ local: /^(localhost|127\.\d+\.\d+\.\d+|::1|0\.0\.0\.0)$/.test(hostname)
69
+ };
70
+ }
71
+ var valuesFile = ".envs/values.yml";
72
+ function envFiles(ctx) {
73
+ return [
74
+ { location: valuesFile, mode: ctx.valuesMode },
75
+ ...ctx.dotenvs.map((d) => ({ location: `${d.name}/.env`, mode: d.mode }))
76
+ ];
77
+ }
78
+ var rules = [
79
+ {
80
+ id: "tracked-values",
81
+ description: "values.yml is tracked by git.",
82
+ async check(ctx) {
83
+ if (!await ctx.isTracked(ctx.root, valuesFile))
84
+ return [];
85
+ return [
86
+ {
87
+ location: valuesFile,
88
+ message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
89
+ }
90
+ ];
91
+ }
92
+ },
93
+ {
94
+ id: "unignored-values",
95
+ description: "values.yml is neither tracked nor ignored by git.",
96
+ async check(ctx) {
97
+ if (await ctx.isTracked(ctx.root, valuesFile))
98
+ return [];
99
+ if (await ctx.isIgnored(ctx.root, valuesFile))
100
+ return [];
101
+ return [
102
+ {
103
+ location: valuesFile,
104
+ message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
105
+ }
106
+ ];
107
+ }
108
+ },
109
+ {
110
+ id: "open-permissions",
111
+ description: "values.yml is readable by other users.",
112
+ check({ valuesMode }) {
113
+ if (valuesMode === undefined || (valuesMode & 63) === 0)
114
+ return [];
115
+ return [
116
+ {
117
+ location: valuesFile,
118
+ message: `values.yml is readable by other users (mode ${(valuesMode & 511).toString(8)}). Run \`chmod 600 .envs/values.yml\`.`
119
+ }
120
+ ];
121
+ }
122
+ },
123
+ {
124
+ id: "executable-files",
125
+ description: "values.yml or a worktree's .env has execute permission.",
126
+ check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
127
+ location: f.location,
128
+ message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
129
+ }))
130
+ },
131
+ {
132
+ id: "writable-files",
133
+ description: "values.yml or a worktree's .env is writable by other users.",
134
+ check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
135
+ location: f.location,
136
+ message: `The file is writable by group or other users (mode ${(f.mode & 511).toString(8)}), who could change your environment. Run \`chmod go-w\` on it.`
137
+ }))
138
+ },
139
+ {
140
+ id: "tracked-dotenv",
141
+ description: "A worktree's .env is tracked by git.",
142
+ async check(ctx) {
143
+ const found = [];
144
+ for (const { name, path } of ctx.dotenvs) {
145
+ if (!await ctx.isTracked(path, ".env"))
146
+ continue;
147
+ found.push({
148
+ location: `${name}/.env`,
149
+ message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
150
+ });
151
+ }
152
+ return found;
153
+ }
154
+ },
155
+ {
156
+ id: "unignored-dotenv",
157
+ description: "A worktree's .env is neither tracked nor ignored by git.",
158
+ async check(ctx) {
159
+ const found = [];
160
+ for (const { name, path } of ctx.dotenvs) {
161
+ if (await ctx.isTracked(path, ".env"))
162
+ continue;
163
+ if (await ctx.isIgnored(path, ".env"))
164
+ continue;
165
+ found.push({
166
+ location: `${name}/.env`,
167
+ message: "The .env is not ignored by git. Add `.env` to .gitignore."
168
+ });
169
+ }
170
+ return found;
171
+ }
172
+ },
173
+ {
174
+ id: "weak-secret",
175
+ description: "A sensitive variable is empty or has an easily guessed value.",
176
+ check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
177
+ location: e.location,
178
+ message: `${e.key} has an empty or easily guessed value.`
179
+ }))
180
+ },
181
+ {
182
+ id: "shared-secret",
183
+ description: "A sensitive variable is in defaults, so it goes to every worktree.",
184
+ check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
185
+ location: e.location,
186
+ message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
187
+ }))
188
+ },
189
+ {
190
+ id: "secret-pattern",
191
+ description: "A value has the format of a well-known secret.",
192
+ check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
193
+ location: e.location,
194
+ message: `${e.key} looks like ${name}.`
195
+ })))
196
+ },
197
+ {
198
+ id: "url-credentials",
199
+ description: "A remote URL embeds a password.",
200
+ check: (ctx) => entries(ctx).filter((e) => {
201
+ const url = parseUrl(e.text);
202
+ return url?.hasPassword && !url.local;
203
+ }).map((e) => ({
204
+ location: e.location,
205
+ message: `${e.key} embeds a password in its URL.`
206
+ }))
207
+ },
208
+ {
209
+ id: "insecure-url",
210
+ description: "A remote URL uses an unencrypted protocol.",
211
+ check: (ctx) => entries(ctx).filter((e) => {
212
+ const url = parseUrl(e.text);
213
+ return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
214
+ }).map((e) => ({
215
+ location: e.location,
216
+ message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
217
+ }))
218
+ }
219
+ ];
220
+
221
+ // src/envs.ts
8
222
  var HELP = `Usage: envs <command>
9
223
 
10
224
  Commands:
11
225
  init Create the .envs/ directory (.gitignore, values.yml)
12
226
  pull Read each worktree's .env and write it into .envs/values.yml
13
227
  push Write defaults and .envs/values.yml into the .env of each worktree
228
+ lint Warn about insecure values and unprotected secret files
14
229
  edit Pull, open .envs/values.yml in VS Code (code -w), then push
15
230
  help Show this help message
16
231
  `;
@@ -67,6 +282,18 @@ function parseDotenv(text) {
67
282
  }
68
283
  return result;
69
284
  }
285
+ function parseValue(value) {
286
+ if (value === "true")
287
+ return true;
288
+ if (value === "false")
289
+ return false;
290
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
291
+ const number = Number(value);
292
+ if (String(number) === value)
293
+ return number;
294
+ }
295
+ return value;
296
+ }
70
297
  function formatDotenvValue(value) {
71
298
  if (value !== "" && !/[\s#"'\\$]/.test(value))
72
299
  return value;
@@ -155,7 +382,7 @@ async function pull(ctx) {
155
382
  delete byWorktree[name];
156
383
  }
157
384
  for (const [key, value] of Object.entries(parsed)) {
158
- (envs[key] ??= {})[name] = value;
385
+ (envs[key] ??= {})[name] = parseValue(value);
159
386
  }
160
387
  ctx.log(`Pulled ${name}`);
161
388
  }
@@ -166,6 +393,51 @@ async function pull(ctx) {
166
393
  await Bun.write(valuesPath, Bun.YAML.stringify(values, null, 2));
167
394
  return 0;
168
395
  }
396
+ async function isIgnored(dir, file) {
397
+ const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
398
+ return result.exitCode === 0;
399
+ }
400
+ async function isTracked(dir, file) {
401
+ const result = await $`git ls-files --error-unmatch ${file}`.cwd(dir).nothrow().quiet();
402
+ return result.exitCode === 0;
403
+ }
404
+ async function lint(ctx) {
405
+ const read = await readValues(ctx);
406
+ if (!read)
407
+ return 1;
408
+ const root = await findRoot(ctx.cwd);
409
+ const worktrees = await listWorktrees(ctx.cwd);
410
+ const modeOf = async (path) => process.platform === "win32" ? undefined : (await stat(path)).mode;
411
+ const dotenvs = [];
412
+ for (const worktree of worktrees) {
413
+ const path = join(worktree.path, ".env");
414
+ if (await Bun.file(path).exists()) {
415
+ dotenvs.push({ ...worktree, mode: await modeOf(path) });
416
+ }
417
+ }
418
+ const lintContext = {
419
+ values: read.values,
420
+ root,
421
+ valuesMode: await modeOf(read.path),
422
+ dotenvs,
423
+ isTracked,
424
+ isIgnored
425
+ };
426
+ let count = 0;
427
+ for (const rule of rules) {
428
+ for (const { location, message } of await rule.check(lintContext)) {
429
+ ctx.error(`warning [${rule.id}] ${location}: ${message}`);
430
+ count++;
431
+ }
432
+ }
433
+ if (count === 0) {
434
+ ctx.log("No security problems found.");
435
+ return 0;
436
+ }
437
+ ctx.error(`
438
+ ${count} warning(s) found.`);
439
+ return 1;
440
+ }
169
441
  async function edit(ctx) {
170
442
  const pulled = await pull(ctx);
171
443
  if (pulled !== 0)
@@ -190,6 +462,8 @@ async function run(args, ctx = defaultContext()) {
190
462
  return await push(ctx);
191
463
  case "pull":
192
464
  return await pull(ctx);
465
+ case "lint":
466
+ return await lint(ctx);
193
467
  case "edit":
194
468
  return await edit(ctx);
195
469
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",