@jondotsoy/envs 0.1.9-alpha.1 → 0.1.9-alpha.3

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 CHANGED
@@ -43,7 +43,7 @@ A walkthrough with a screenshot of each action is in [docs/ui.md](docs/ui.md) (i
43
43
  | Command | What it does |
44
44
  | --- | --- |
45
45
  | `envs edit` | Runs `init` if `.envs/values.yml` does not exist yet, then runs `pull`, opens `values.yml` with `code -w` and, when the editor closes, runs `push`. If the editor fails, it does not `push`. |
46
- | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Open the address it prints (it carries a one-time access token); requests without the token, from another host name or from another site are refused (see [docs/ui.md](docs/ui.md)). Stop the server with Ctrl+C. |
46
+ | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Open the address it prints (it carries a single-use access token: the first visit trades it for a session cookie, and using it prints a fresh single-use address; if the browser cannot be opened, the address is printed to open by hand; the token never appears in `ps`); requests without the token, from another host name or from another site are refused (see [docs/ui.md](docs/ui.md)). Stop the server with Ctrl+C. |
47
47
  | `envs init` | Creates `.envs/`, `.envs/.gitignore` (containing `*`) and an empty `.envs/values.yml`. Does not overwrite existing files. |
48
48
  | `envs pull` | Reads the `.env` of the main repo and of each worktree and writes it to `values.yml`. Variables that are no longer in a `.env` are removed from `values.yml` for that worktree. |
49
49
  | `envs push` | Writes `values.yml` to the `.env` of each worktree. Updates existing variables in place, appends new ones at the end, keeps comments, refuses (writing nothing) a variable name that is not valid or a value with a line break, and removes the orphan variables: those `envs` manages (they are, or were, in `values.yml` or its history) but that the worktree no longer resolves, for example after `envs restore`. Variables that `envs` never saw are left alone. |
package/SECURITY.md CHANGED
@@ -27,8 +27,8 @@ I will reply as soon as possible and we will coordinate the fix and the disclosu
27
27
  | --- | --- | --- |
28
28
  | `envs init` | — | Creates `<root>/.envs/`, `<root>/.envs/.gitignore` and `<root>/.envs/values.yml`, only if they do not exist. Never overwrites. |
29
29
  | `envs pull` | `<worktree>/.env` of each worktree | Rewrites `<root>/.envs/values.yml`. The file is serialized again, so YAML comments are lost. |
30
- | `envs push` | `<root>/.envs/values.yml` | Creates or updates `<worktree>/.env` of each worktree that has values. Updates existing variables in place, appends new ones at the end and keeps the rest of the file, except the orphan variables: those `envs` manages (they are or were in `values.yml` or its history) that the worktree no longer resolves are removed. If there are `defaults`, this includes all worktrees. |
31
- | `envs edit` | Same as `pull` and `push` | Same as `pull` and `push`. It also runs `code -w <root>/.envs/values.yml`. With `--ui` it serves the web editor instead (see below). |
30
+ | `envs push` | `<root>/.envs/values.yml` | Creates or updates `<worktree>/.env` of each worktree that has values. Updates existing variables in place, appends new ones at the end and keeps the rest of the file (it sets the mode of every `.env` it writes to `600`, so another user, such as a container mounting it, can no longer read it), except the orphan variables: those `envs` manages (they are or were in `values.yml` or its history) that the worktree no longer resolves are removed. If there are `defaults`, this includes all worktrees. |
31
+ | `envs edit` | Same as `pull` and `push` | Same as `pull` and `push`. It also runs `code -w <root>/.envs/values.yml`. With `--ui` it serves the web editor instead and runs the system opener (`xdg-open`, `open` or `explorer`) on a temporary redirect page under the OS temp directory (see below). |
32
32
  | `envs use`, `envs restore` | `<root>/.envs/values.yml`, `<root>/.envs/history.ndjson` | Rewrite `values.yml` and then act as `push`. |
33
33
  | `envs config` | `<root>/.envs/config.yml` | Creates or updates `<root>/.envs/config.yml` (mode `600`). |
34
34
 
@@ -51,7 +51,14 @@ Additionally:
51
51
 
52
52
  ## Web editor (`envs edit --ui`)
53
53
 
54
- The server listens on `127.0.0.1` only and exposes the values of every `.env`, secrets included, so each request must pass these checks: a random per-run access token (in the printed URL, then an `HttpOnly`, `SameSite=Strict` cookie), a `Host` of this server (no DNS rebinding), an `Origin` of this server, `application/json` bodies and a body of at most 5 MiB; it never answers CORS preflights. Open only the address printed by the command and do not share it: it carries the token. See [docs/ui.md](docs/ui.md).
54
+ The server listens on `127.0.0.1` only and exposes the values of every `.env`, secrets included, so each request must pass these checks: a random per-run access token (in the printed URL, **single-use**: the first visit trades it for a different session secret, set as an `HttpOnly`, `SameSite=Strict` cookie), a `Host` of this server (no DNS rebinding), an `Origin` of this server, `application/json` bodies and a body of at most 5 MiB; it never answers CORS preflights. Open only the address printed by the command and do not share it: it carries the token. See [docs/ui.md](docs/ui.md).
55
+
56
+ How the access token is handled:
57
+
58
+ - **Single-use.** Once the token in the URL is used it stops working (`401`), so a copy left in the browser history, the terminal or a screenshot is useless afterwards. Using it prints a fresh single-use URL to stdout (`New single-use URL: …`) to open the editor from another browser; every URL gives the same session. The session secret is never in a URL.
59
+ - **Not in `ps`.** The URL is not passed as an argument to `xdg-open`/`open`/`explorer`, where any user could read it in the process list. The opener receives a private temporary page (a directory made with `mkdtemp`, file mode `600`) that redirects to the URL. The page is removed as soon as the token is used, after 60 seconds at most, when the process exits, or when the opener fails.
60
+ - **If the browser cannot be opened** the command prints `Could not open the browser. Open this address manually: …`.
61
+ - **Remaining exposure.** The token is printed to stdout and is in the address bar until it is used, so anyone who can read your screen or your terminal output in that moment can use it before you do. If the address is opened by someone else first, your own visit gets a `401`: stop the server (Ctrl+C) and start it again.
55
62
 
56
63
  ## The history
57
64
 
@@ -62,7 +69,7 @@ The server listens on `127.0.0.1` only and exposes the values of every `.env`, s
62
69
  `envs push` prints one line for each variable whose value changes, in the form `↻ NAME=VALUE → branch, ...`, and `envs pull` prints `↓ pulling branch, ... - N variables`. To keep secrets out of the terminal and CI logs:
63
70
 
64
71
  - The value is replaced by `********` when the variable name contains a sensitive word: `SECRET`, `PASSWORD`, `PASSWD`, `PWD`, `TOKEN`, `KEY` (including `API_KEY` and `PRIVATE_KEY`), `CREDENTIAL`, `AUTH`, `SALT` or `SIGNING`. The match is case-insensitive and applies to whole `_`-separated words, so `KEY_FOO` and `AWS_BUCKET_SECRET` are masked but `MONKEY` is not.
65
- - A URL keeps its host and loses its secrets: `postgres://user:********@host/db?token=********` (the password and the values of sensitive query parameters are masked). A secret stored in a variable with an innocuous name and no URL form is still printed as is.
72
+ - A URL keeps its host and loses its secrets: `postgres://user:********@host/db?token=********` (the password and the values of sensitive query parameters are masked). A value in a well-known secret format (AWS key, GitHub or Slack token, `sk-…` key, private key) is masked whatever the variable is called; any other secret stored in a variable with an innocuous name and no URL form is still printed as is.
66
73
  - `envs pull` never prints values, only branch names and the number of variables.
67
74
  - `envs lint` messages never include the matched value.
68
75
  - Set `NO_COLOR` to a non-empty value to print the output without colors.
package/envs.js CHANGED
@@ -17,8 +17,9 @@ function dotenvProblem(key, text) {
17
17
  if (!/^[\w.-]+$/.test(key)) {
18
18
  return "the name is not a valid variable name (only letters, digits, `_`, `.` and `-`)";
19
19
  }
20
- if (text.includes("'") && text.includes('"') && text.includes("`")) {
21
- return "the value has single, double and backtick quotes at once, so no .env quoting can hold it as is";
20
+ const needsBackticks = text.includes("'") && (text.includes('"') || /\\[nr]/.test(text));
21
+ if (needsBackticks && text.includes("`")) {
22
+ return "the value needs backtick quoting (it has a single quote and a double quote or a literal `\\n`) and also has a backtick, so no .env quoting can hold it as is";
22
23
  }
23
24
  if (/[\r\n]/.test(text)) {
24
25
  return "the value has a line break, which a .env file cannot hold safely (write it on one line, with `\\n` escapes if the application expects them)";
@@ -97,10 +98,10 @@ var init_lint_rules = __esm(() => {
97
98
  "xxx"
98
99
  ]);
99
100
  SECRET_PATTERNS = [
100
- [/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
101
- [/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
102
- [/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
103
- [/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
101
+ [/(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}/, "an AWS access key id"],
102
+ [/(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
103
+ [/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
104
+ [/(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
104
105
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
105
106
  ];
106
107
  rules = [
@@ -498,7 +499,9 @@ function presentedToken(req) {
498
499
  }
499
500
  async function serveUi(ctx) {
500
501
  const assets = await loadAssets();
501
- const token = randomBytes(24).toString("base64url");
502
+ let opened;
503
+ let token = randomBytes(24).toString("base64url");
504
+ const session = randomBytes(24).toString("base64url");
502
505
  const guard = (handler) => async (req) => {
503
506
  const port = server.port;
504
507
  const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
@@ -511,15 +514,18 @@ async function serveUi(ctx) {
511
514
  }
512
515
  const url = new URL(req.url);
513
516
  if (url.pathname === "/" && sameToken(url.searchParams.get("token"), token)) {
517
+ token = randomBytes(24).toString("base64url");
518
+ ctx.log(`New single-use URL: ${urlWith(token)}`);
519
+ opened?.then((cleanup) => cleanup?.(), () => {});
514
520
  return new Response(null, {
515
521
  status: 302,
516
522
  headers: {
517
523
  location: "/",
518
- "set-cookie": `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`
524
+ "set-cookie": `${TOKEN_COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`
519
525
  }
520
526
  });
521
527
  }
522
- if (!sameToken(presentedToken(req), token)) {
528
+ if (!sameToken(presentedToken(req), session)) {
523
529
  return new Response("Unauthorized: open the address printed by `envs edit --ui`.", { status: 401 });
524
530
  }
525
531
  if (req.method !== "GET" && req.method !== "HEAD" && !req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
@@ -573,9 +579,15 @@ async function serveUi(ctx) {
573
579
  return asset ? new Response(asset) : new Response("Not found", { status: 404 });
574
580
  })
575
581
  });
576
- const url = `http://127.0.0.1:${server.port}/?token=${token}`;
582
+ const urlWith = (t) => `http://127.0.0.1:${server.port}/?token=${t}`;
583
+ const url = urlWith(token);
577
584
  ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
578
- await ctx.openUrl?.(url);
585
+ try {
586
+ opened = ctx.openUrl?.(url);
587
+ await opened;
588
+ } catch {
589
+ ctx.log(`Could not open the browser. Open this address manually: ${url}`);
590
+ }
579
591
  await (ctx.waitForExit ?? (() => new Promise(() => {})))();
580
592
  await server.stop(true);
581
593
  return 0;
@@ -592,6 +604,8 @@ import { AsyncLocalStorage } from "async_hooks";
592
604
  import { chmod, mkdir, open, rename, unlink, writeFile } from "fs/promises";
593
605
  import { basename, dirname, join as join2 } from "path";
594
606
  import { stat } from "fs/promises";
607
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
608
+ import { tmpdir } from "os";
595
609
  import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
596
610
  import { appendFile, truncate } from "fs/promises";
597
611
  var {$ } = globalThis.Bun;
@@ -871,7 +885,7 @@ function parseDotenv(text) {
871
885
  continue;
872
886
  let value = match[2].trim();
873
887
  const quote = value[0];
874
- if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) {
888
+ if ((quote === '"' || quote === "'" || quote === "`") && value.lastIndexOf(quote) > 0) {
875
889
  value = value.slice(1, value.lastIndexOf(quote));
876
890
  } else {
877
891
  value = value.replace(/\s+#.*$/, "");
@@ -1566,9 +1580,21 @@ Commands:
1566
1580
  },
1567
1581
  openUrl: async (url) => {
1568
1582
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
1583
+ const dir = mkdtempSync(join2(tmpdir(), "envs-"));
1584
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
1569
1585
  try {
1570
- await Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1571
- } catch {}
1586
+ const page = join2(dir, "open.html");
1587
+ writeFileSync(page, `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${url}"><script>location.replace(${JSON.stringify(url)})</script>`, { mode: 384 });
1588
+ process.once("exit", cleanup);
1589
+ setTimeout(cleanup, 60000).unref();
1590
+ const code = await Bun.spawn([opener, page], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1591
+ if (code !== 0)
1592
+ throw new Error(`${opener} exited with code ${code}`);
1593
+ return cleanup;
1594
+ } catch (error) {
1595
+ cleanup();
1596
+ throw error;
1597
+ }
1572
1598
  },
1573
1599
  waitForExit: () => new Promise((resolve) => {
1574
1600
  process.once("SIGINT", () => resolve());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.1.9-alpha.1",
3
+ "version": "0.1.9-alpha.3",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",