@jondotsoy/envs 0.1.9-alpha.0 → 0.1.9-alpha.2

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/SECURITY.md +2 -2
  2. package/envs.js +26 -16
  3. package/package.json +1 -1
package/SECURITY.md CHANGED
@@ -27,7 +27,7 @@ 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. |
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
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). |
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`). |
@@ -62,7 +62,7 @@ The server listens on `127.0.0.1` only and exposes the values of every `.env`, s
62
62
  `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
63
 
64
64
  - 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.
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 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
66
  - `envs pull` never prints values, only branch names and the number of variables.
67
67
  - `envs lint` messages never include the matched value.
68
68
  - Set `NO_COLOR` to a non-empty value to print the output without colors.
package/envs.js CHANGED
@@ -17,11 +17,18 @@ 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
+ 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";
23
+ }
20
24
  if (/[\r\n]/.test(text)) {
21
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)";
22
26
  }
23
27
  return;
24
28
  }
29
+ function hasSecretPattern(text) {
30
+ return SECRET_PATTERNS.some(([pattern]) => pattern.test(text));
31
+ }
25
32
  function isSensitiveKey(key) {
26
33
  return SENSITIVE_KEY.test(key);
27
34
  }
@@ -91,10 +98,10 @@ var init_lint_rules = __esm(() => {
91
98
  "xxx"
92
99
  ]);
93
100
  SECRET_PATTERNS = [
94
- [/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
95
- [/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
96
- [/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
97
- [/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"],
98
105
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
99
106
  ];
100
107
  rules = [
@@ -130,13 +137,10 @@ var init_lint_rules = __esm(() => {
130
137
  },
131
138
  {
132
139
  id: "open-permissions",
133
- description: "values.yml or the history is readable by other users.",
134
- check: (ctx) => [
135
- { file: valuesFile, mode: ctx.valuesMode },
136
- ...ctx.hasHistory ? [{ file: historyFile, mode: ctx.historyMode }] : []
137
- ].filter(({ mode }) => mode !== undefined && (mode & 63) !== 0).map(({ file, mode }) => ({
138
- location: file,
139
- message: `${file.slice(".envs/".length)} is readable by other users (mode ${(mode & 511).toString(8)}). Run \`chmod 600 ${file}\`.`
140
+ description: "values.yml, the history or a worktree's .env is readable by other users.",
141
+ check: (ctx) => envFiles(ctx).filter(({ mode }) => mode !== undefined && (mode & 63) !== 0).map(({ location, mode }) => ({
142
+ location,
143
+ message: `${location.replace(/^\.envs\//, "")} is readable by other users (mode ${(mode & 511).toString(8)}). Run \`chmod 600 ${location}\`.`
140
144
  }))
141
145
  },
142
146
  {
@@ -868,7 +872,7 @@ function parseDotenv(text) {
868
872
  continue;
869
873
  let value = match[2].trim();
870
874
  const quote = value[0];
871
- if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) {
875
+ if ((quote === '"' || quote === "'" || quote === "`") && value.lastIndexOf(quote) > 0) {
872
876
  value = value.slice(1, value.lastIndexOf(quote));
873
877
  } else {
874
878
  value = value.replace(/\s+#.*$/, "");
@@ -890,9 +894,13 @@ function parseValue(value) {
890
894
  return value;
891
895
  }
892
896
  function formatDotenvValue(value) {
893
- if (value !== "" && !/[\s#"'\\$]/.test(value))
897
+ if (value !== "" && !/[\s#"'`\\$]/.test(value))
894
898
  return value;
895
- return value.includes('"') ? `'${value}'` : `"${value}"`;
899
+ if (!value.includes('"') && !/\\[nr]/.test(value))
900
+ return `"${value}"`;
901
+ if (!value.includes("'"))
902
+ return `'${value}'`;
903
+ return `\`${value}\``;
896
904
  }
897
905
  function updateDotenv(text, entries, remove = new Set) {
898
906
  const pending = new Map(Object.entries(entries));
@@ -1142,7 +1150,9 @@ async function pushLocked(ctx, only) {
1142
1150
  }
1143
1151
  for (const key of orphans)
1144
1152
  removals.set(key, [...removals.get(key) ?? [], label]);
1145
- await writeFile(join2(path, ".env"), updateDotenv(current, entries, orphans), { mode: 384 });
1153
+ const dotenvPath = join2(path, ".env");
1154
+ await writeFile(dotenvPath, updateDotenv(current, entries, orphans), { mode: 384 });
1155
+ await chmod(dotenvPath, 384).catch(() => {});
1146
1156
  }
1147
1157
  for (const [line, names] of changes) {
1148
1158
  ctx.log(yellow(`\u21BB ${line} \u2192 ${names.join(", ")}`));
@@ -1544,7 +1554,7 @@ Commands:
1544
1554
  history Manage the history (history redact: remove secrets in clear from an existing history)
1545
1555
  config Read or change .envs/config.yml (config set <key> <value>, config get [key], config delete <key>)
1546
1556
  help Show this help message
1547
- `, colorsDisabled = () => Boolean(process.env.NO_COLOR), MASK = "********", isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key), URL_SECRET, hasUrlSecret = (value) => URL_SECRET.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) && sensitiveQuery(value), sensitiveQuery = (value) => [...value.matchAll(/[?&]([^=&\s#]+)=([^&\s#]*)/g)].some(([, name]) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_"))), maskUrlSecrets = (value) => value.replace(URL_SECRET, `$1${MASK}$3`).replace(/([?&])([^=&\s#]+)=([^&\s#]*)/g, (all, sep, name) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")) ? `${sep}${name}=${MASK}` : all), isSecretEntry = (name, value) => isMaskedKey(name) || hasUrlSecret(value), displayValue = (name, value) => isMaskedKey(name) ? MASK : formatDotenvValue(maskUrlSecrets(value)), paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`, green = (text) => paint(32, text), yellow = (text) => paint(33, text), red = (text) => paint(31, text), defaultContext = () => ({
1557
+ `, colorsDisabled = () => Boolean(process.env.NO_COLOR), MASK = "********", isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key), URL_SECRET, hasUrlSecret = (value) => URL_SECRET.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) && sensitiveQuery(value), sensitiveQuery = (value) => [...value.matchAll(/[?&]([^=&\s#]+)=([^&\s#]*)/g)].some(([, name]) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_"))), maskUrlSecrets = (value) => value.replace(URL_SECRET, `$1${MASK}$3`).replace(/([?&])([^=&\s#]+)=([^&\s#]*)/g, (all, sep, name) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")) ? `${sep}${name}=${MASK}` : all), isSecretEntry = (name, value) => isMaskedKey(name) || hasUrlSecret(value) || hasSecretPattern(value), displayValue = (name, value) => isMaskedKey(name) || hasSecretPattern(value) ? MASK : formatDotenvValue(maskUrlSecrets(value)), paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`, green = (text) => paint(32, text), yellow = (text) => paint(33, text), red = (text) => paint(31, text), defaultContext = () => ({
1548
1558
  cwd: process.cwd(),
1549
1559
  log: (message) => console.log(message),
1550
1560
  error: (message) => console.error(message),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.1.9-alpha.0",
3
+ "version": "0.1.9-alpha.2",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",