@kaynooo/env-tools 0.6.0 → 0.7.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/dist/redact.js CHANGED
@@ -276,12 +276,27 @@ function le(i = {}) {
276
276
  var t = le();
277
277
 
278
278
  // ../kit/src/style.ts
279
+ var MARKS = {
280
+ ok: { symbol: "\u2713", word: "ok:", paint: (colors) => colors.green },
281
+ warn: { symbol: "!", word: "warning:", paint: (colors) => colors.yellow },
282
+ problem: { symbol: "\u2717", word: "error:", paint: (colors) => colors.red },
283
+ note: { symbol: "\xB7", word: "note:", paint: (colors) => colors.dim }
284
+ };
279
285
  function takesControlCodes(stream, env = process2.env) {
280
286
  return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
281
287
  }
282
288
  function paint(stream, env = process2.env) {
283
289
  return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
284
290
  }
291
+ function marked(mark, text, stream, detail, env = process2.env) {
292
+ const colors = paint(stream, env);
293
+ const { symbol, word, paint: tint } = MARKS[mark];
294
+ const head = takesControlCodes(stream, env) ? tint(colors)(symbol) : word;
295
+ return `${head} ${text}${detail === undefined ? "" : ` ${colors.dim(detail)}`}`;
296
+ }
297
+ function ok(text, detail) {
298
+ console.log(marked("ok", text, process2.stdout, detail));
299
+ }
285
300
  var STATUSES = {
286
301
  passed: { symbol: "\u2713", words: ["passed", "success", "succeeded", "ok"], paint: (colors) => colors.green },
287
302
  failed: { symbol: "\u2717", words: ["failed", "failure", "error", "errored"], paint: (colors) => colors.red },
@@ -549,6 +564,11 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
549
564
  fail(`no file ${path}`);
550
565
  return file.text();
551
566
  }
567
+ // ../kit/src/progress.ts
568
+ var ESC = String.fromCharCode(27);
569
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
570
+ var RESET_STYLE = `${ESC}[0m`;
571
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
552
572
  // ../kit/src/cli.ts
553
573
  import process5 from "process";
554
574
  var LEVEL_OPTIONS = {
@@ -755,11 +775,20 @@ async function runMain({ name, version, run, redact }, argv = process5.argv.slic
755
775
  return reportThrown(name, error, redact, wantsJson(argv));
756
776
  }
757
777
  }
778
+ // ../kit/src/logs.ts
779
+ var DAY_MS = 24 * 60 * 60 * 1000;
780
+ // ../kit/src/secrets.ts
781
+ var SECRET_PART = /token|secret|passw|credential|private|dsn|apikey|accesskey|key$/i;
782
+ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "session", "jwt", "ssn", "iban", "card", "cvv", "otp"]);
783
+ var WORD_BREAKS = /[^a-zA-Z\d]+|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/;
784
+ function isSecretName(name) {
785
+ return SECRET_PART.test(name) || name.split(WORD_BREAKS).some((word) => SECRET_WORDS.has(word.toLowerCase()));
786
+ }
758
787
  // package.json
759
- var version = "0.6.0";
788
+ var version = "0.7.0";
760
789
 
761
790
  // src/files.ts
762
- import { chmod, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
791
+ import { chmod, chown, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
763
792
  import { basename, dirname, join } from "path";
764
793
 
765
794
  // src/dotenv.ts
@@ -768,7 +797,9 @@ var EXPORTED = /^\s*export\s/;
768
797
  var INLINE_COMMENT = /\s+#.*$/;
769
798
  var CARRIAGE_RETURN = /\r$/;
770
799
  var ESCAPES = { n: `
771
- `, r: "\r", t: "\t", '"': '"', "\\": "\\" };
800
+ `, r: "\r", t: "\t", '"': '"', "\\": "\\", $: "$" };
801
+ var LINE_BREAK = /[\r\n]/;
802
+ var RAW_ONLY = /["\\]/;
772
803
  var NEEDS_QUOTES = /[\s#"'\\$`]/;
773
804
  function closingQuote(text, quote) {
774
805
  for (let index = 0;index < text.length; index++) {
@@ -779,6 +810,9 @@ function closingQuote(text, quote) {
779
810
  }
780
811
  return -1;
781
812
  }
813
+ function unescapeDollar(text) {
814
+ return text.replaceAll("\\$", "$");
815
+ }
782
816
  function unescapeDouble(text) {
783
817
  let out = "";
784
818
  for (let index = 0;index < text.length; index++) {
@@ -807,7 +841,7 @@ function parseDotenv(text) {
807
841
  const start = rest.trimStart();
808
842
  const quote = start[0];
809
843
  if (quote !== '"' && quote !== "'" && quote !== "`") {
810
- lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, "").trim() });
844
+ lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, "").trim()) });
811
845
  continue;
812
846
  }
813
847
  let body = start.slice(1);
@@ -822,7 +856,7 @@ ${physical[index] ?? ""}`;
822
856
  end = closingQuote(body, quote);
823
857
  }
824
858
  const inner = end === -1 ? body : body.slice(0, end);
825
- lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner });
859
+ lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) });
826
860
  }
827
861
  return { lines };
828
862
  }
@@ -841,8 +875,10 @@ function entries(file) {
841
875
  function formatAssignment(key, value) {
842
876
  if (value === "" || !NEEDS_QUOTES.test(value))
843
877
  return `${key}=${value}`;
878
+ if (RAW_ONLY.test(value) && !value.includes("'") && !LINE_BREAK.test(value))
879
+ return `${key}='${value.replaceAll("$", "\\$")}'`;
844
880
  const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("\r", "\\r").replaceAll(`
845
- `, "\\n");
881
+ `, "\\n").replaceAll("$", "\\$");
846
882
  return `${key}="${escaped}"`;
847
883
  }
848
884
  function setLine(file, key, value, raw) {
@@ -872,20 +908,38 @@ function lastLine(file, key) {
872
908
  }
873
909
 
874
910
  // src/mask.ts
875
- var MIN_KNOWN_LENGTH = 6;
876
- var SECRET_KEY = /TOKEN|SECRET|PASSWORD|KEY|PRIVATE|DSN/i;
877
- var URL_PASSWORD = /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:([^\s@/]+)@/dgi;
911
+ var MIN_KNOWN_LENGTH = 4;
912
+ var KEY_MASK = "*** private key redacted ***";
913
+ var PLAIN_VALUE = /^(?:true|false|null|none|nil|yes|no|on|off|undefined)$/i;
914
+ var PLACEHOLDER = /^[$%{<(*/]/;
915
+ var URL_PASSWORD = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]*:([^\s'"]{1,1024})@/dgi;
916
+ var PORT = /^\d{1,5}$/;
878
917
  var ESCAPE2 = /[.*+?^${}()|[\]\\]/g;
918
+ function isPlaceholder(value) {
919
+ return PLACEHOLDER.test(value) || PLAIN_VALUE.test(value);
920
+ }
879
921
  var TOKEN_FORMATS = [
880
- ["GitHub token", /\b(?:gh[pousr]_|github_pat_)\w{20,}/g],
881
- ["GitLab token", /\bglpat-[\w-]{20,}/g],
882
- ["API key", /\bsk-[\w-]{20,}/g],
883
- ["Slack token", /\bxox[abprs]-[\w-]{10,}/g],
884
- ["JWT", /\beyJ[\w-]+\.[\w-]+\.[\w-]+/g]
922
+ { kind: "GitHub token", pattern: /\b(?:gh[pousr]_|github_pat_)\w{20,}/dg },
923
+ { kind: "GitLab token", pattern: /\bglpat-[\w-]{20,}/dg },
924
+ { kind: "API key", pattern: /\bsk-[\w-]{20,}/dg },
925
+ { kind: "Slack token", pattern: /\bx(?:ox[abprs]|app)-[\w-]{10,}/dg },
926
+ { kind: "JWT", pattern: /\beyJ[\w-]+\.[\w-]+\.[\w-]+/dg },
927
+ { kind: "AWS access key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/dg },
928
+ { kind: "npm token", pattern: /\bnpm_[A-Za-z0-9]{30,}/dg },
929
+ { kind: "Stripe key", pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}/dg },
930
+ { kind: "Google API key", pattern: /\bAIza[\w-]{35}/dg },
931
+ { kind: "SendGrid key", pattern: /\bSG\.[\w-]{16,}\.[\w-]{16,}/dg },
932
+ { kind: "Authorization header", pattern: /\bAuthorization["']?\s*[:=]\s*["']?(?:Bearer|Basic|Token|Digest)\s+([^\s"',;]{8,})/dgi, skip: isPlaceholder },
933
+ { kind: "Bearer token", pattern: /\bBearer\s+((?=[\w.~+/=-]*\d)[\w.~+/=-]{20,})/dg },
934
+ { kind: "password", pattern: /(?<![\w-])(?:[\w-]{0,30}[_-])?(?:password|passwd|secret|api[_-]?key)(?:[_-][\w-]{0,30})?=([^\s"'&;,]{4,})/dgi, skip: isPlaceholder },
935
+ { kind: "password", pattern: /(?<![\w-])["']?(?:[\w-]{0,30}[_-])?(?:password|passwd|secret|api[_-]?key)(?:[_-][\w-]{0,30})?["']?\s*[:=]\s*(["'])((?:\\.|(?!\1)[^\\\n]){4,})\1/dgi, skip: isPlaceholder }
885
936
  ];
886
- var URL_PARTS = /\b[a-z][a-z0-9+.-]*:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
937
+ var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
938
+ var PEM_END = /-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
939
+ var PEM_BODY = /(?<=-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)(?:(?!-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)[\s\S])+/dg;
940
+ var PEM_BEGIN_ALL = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/g;
941
+ var URL_PARTS = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
887
942
  var PATH_SEGMENT = /[^/]+/g;
888
- var QUERY_PARAM = /([^&=]+)=([^&]*)/g;
889
943
  var SECRET_PARAM = /^(?:token|[a-z]+_token|key|api[_-]?key|access[_-]?key|secret|[a-z]+_secret|password|passwd|pwd|sig|signature|auth|authorization)$/i;
890
944
  var WEBHOOK_FORMATS = [
891
945
  ["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
@@ -894,28 +948,41 @@ var WEBHOOK_FORMATS = [
894
948
  ["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
895
949
  ];
896
950
  var OPAQUE_SEGMENT = /^(?:(?=[\w-]{20,}$)(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w-]+|(?=[A-Za-z\d]{32,}$)(?=.*[A-Za-z])(?=.*\d).+)$/;
897
- var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
898
- var PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
899
- function isSecretKey(key) {
900
- return SECRET_KEY.test(key);
901
- }
902
951
  function hint(value) {
903
952
  const shown = Math.min(3, Math.floor(value.length / 4));
904
953
  return `${value.slice(0, shown)}***(${value.length})`;
905
954
  }
955
+ function forms(value) {
956
+ return [...new Set([value, encodeURIComponent(value), JSON.stringify(value).slice(1, -1)])];
957
+ }
906
958
  function knownSecrets(pairs) {
907
959
  const keys = new Map;
908
960
  for (const [key, value] of pairs) {
909
- if (value.length >= MIN_KNOWN_LENGTH && !keys.has(value))
910
- keys.set(value, key);
961
+ if (value.length < MIN_KNOWN_LENGTH || PLAIN_VALUE.test(value))
962
+ continue;
963
+ for (const form of forms(value)) {
964
+ if (!keys.has(form))
965
+ keys.set(form, key);
966
+ }
911
967
  }
912
968
  if (keys.size === 0)
913
969
  return { pattern: undefined, keys };
914
970
  const alternatives = [...keys.keys()].sort((a, b) => b.length - a.length).map((value) => value.replace(ESCAPE2, "\\$&"));
915
971
  return { pattern: new RegExp(`(?<!\\w)(?:${alternatives.join("|")})(?!\\w)`, "g"), keys };
916
972
  }
973
+ function passwordSpans(text) {
974
+ const spans = [];
975
+ for (const url of text.matchAll(URL_PASSWORD)) {
976
+ const [start, end] = url.indices?.[1] ?? [];
977
+ const password = url[1] ?? "";
978
+ const slash = password.indexOf("/");
979
+ if (start !== undefined && end !== undefined && !(slash !== -1 && PORT.test(password.slice(0, slash))))
980
+ spans.push([start, end]);
981
+ }
982
+ return spans;
983
+ }
917
984
  function urlPasswords(text) {
918
- return [...text.matchAll(URL_PASSWORD)].flatMap((url) => url[1] ?? []);
985
+ return passwordSpans(text).map(([start, end]) => text.slice(start, end));
919
986
  }
920
987
  function urlTokens(line) {
921
988
  const found = [];
@@ -942,24 +1009,34 @@ function urlTokens(line) {
942
1009
  }
943
1010
  if (query === undefined || queryStart === undefined)
944
1011
  continue;
945
- for (const param of query.matchAll(QUERY_PARAM)) {
946
- const [name, value] = [param[1] ?? "", param[2] ?? ""];
947
- if (value !== "" && SECRET_PARAM.test(name))
948
- add("URL token", queryStart + param.index + name.length + 1, value);
1012
+ let offset = 0;
1013
+ for (const param of query.split("&")) {
1014
+ const equals = param.indexOf("=");
1015
+ if (equals > 0 && equals < param.length - 1 && SECRET_PARAM.test(param.slice(0, equals)))
1016
+ add("URL token", queryStart + offset + equals + 1, param.slice(equals + 1));
1017
+ offset += param.length + 1;
949
1018
  }
950
1019
  }
951
1020
  return found.filter((match) => match.end > match.start);
952
1021
  }
953
1022
  function findSecrets(line, known) {
954
1023
  const found = [];
955
- for (const url of line.matchAll(URL_PASSWORD)) {
956
- const [start, end] = url.indices?.[1] ?? [];
1024
+ for (const [start, end] of passwordSpans(line))
1025
+ found.push({ start, end, kind: "URL password", mask: "***" });
1026
+ for (const body of line.matchAll(PEM_BODY)) {
1027
+ const [start, end] = body.indices?.[0] ?? [];
957
1028
  if (start !== undefined && end !== undefined)
958
- found.push({ start, end, kind: "URL password", mask: "***" });
1029
+ found.push({ start, end, kind: "private key", mask: KEY_MASK });
959
1030
  }
960
- for (const [kind, pattern] of TOKEN_FORMATS) {
961
- for (const token of line.matchAll(pattern))
962
- found.push({ start: token.index, end: token.index + token[0].length, kind, mask: hint(token[0]) });
1031
+ for (const { kind, pattern, skip } of TOKEN_FORMATS) {
1032
+ for (const token of line.matchAll(pattern)) {
1033
+ const [start, end] = token.indices?.at(-1) ?? [];
1034
+ if (start === undefined || end === undefined)
1035
+ continue;
1036
+ const value = line.slice(start, end);
1037
+ if (!skip?.(value))
1038
+ found.push({ start, end, kind, mask: hint(value) });
1039
+ }
963
1040
  }
964
1041
  found.push(...urlTokens(line));
965
1042
  if (known?.pattern !== undefined) {
@@ -974,6 +1051,13 @@ function findSecrets(line, known) {
974
1051
  }
975
1052
  return kept;
976
1053
  }
1054
+ function openKeyBody(line) {
1055
+ const last = [...line.matchAll(PEM_BEGIN_ALL)].at(-1);
1056
+ if (last === undefined)
1057
+ return;
1058
+ const after = line.slice(last.index + last[0].length);
1059
+ return PEM_END.test(after) ? undefined : after.trim();
1060
+ }
977
1061
  function maskLine(line, known) {
978
1062
  let out = "";
979
1063
  let at = 0;
@@ -983,18 +1067,38 @@ function maskLine(line, known) {
983
1067
  }
984
1068
  return out + line.slice(at);
985
1069
  }
986
- function maskValue(key, value) {
1070
+ function maskValue(key, value, known) {
987
1071
  if (value === "")
988
1072
  return value;
989
- return isSecretKey(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value);
1073
+ return isSecretName(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value, known);
990
1074
  }
991
1075
 
992
1076
  // src/files.ts
993
- var ENV_FILE = /^\.env(?:\..+)?$/;
1077
+ var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
994
1078
  var TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/;
1079
+ var KNOWN_DEPTH = 3;
1080
+ var SKIPPED = new Set(["node_modules", ".git", "vendor"]);
1081
+ var LOCK_WAIT_MS = 5000;
1082
+ var LOCK_STALE_MS = 30000;
1083
+ async function isFileEntry(entry, path) {
1084
+ if (entry.isFile())
1085
+ return true;
1086
+ if (!entry.isSymbolicLink())
1087
+ return false;
1088
+ try {
1089
+ return (await stat(path)).isFile();
1090
+ } catch {
1091
+ return false;
1092
+ }
1093
+ }
995
1094
  async function envFiles(folder = process.cwd()) {
996
1095
  const found = await readdir(folder, { withFileTypes: true });
997
- return found.filter((entry) => entry.isFile() && ENV_FILE.test(entry.name)).map((entry) => entry.name).sort();
1096
+ const names = [];
1097
+ for (const entry of found) {
1098
+ if (ENV_FILE.test(entry.name) && await isFileEntry(entry, join(folder, entry.name)))
1099
+ names.push(entry.name);
1100
+ }
1101
+ return names.sort();
998
1102
  }
999
1103
  async function readEnv(path) {
1000
1104
  const file = Bun.file(path);
@@ -1002,31 +1106,109 @@ async function readEnv(path) {
1002
1106
  fail(`${path} does not exist`);
1003
1107
  return parseDotenv(await file.text());
1004
1108
  }
1005
- async function writeEnv(path, file) {
1109
+ async function writeAtomic(path, text) {
1006
1110
  let target = path;
1007
1111
  let mode = 384;
1112
+ let owner;
1008
1113
  try {
1009
1114
  target = await realpath(path);
1010
- mode = (await stat(target)).mode & 511;
1011
- } catch {}
1115
+ const current = await stat(target);
1116
+ mode = current.mode & 511;
1117
+ owner = current;
1118
+ } catch (error) {
1119
+ if (error.code !== "ENOENT")
1120
+ throw error;
1121
+ }
1012
1122
  const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
1013
1123
  try {
1014
- await writeFile(temp, serializeDotenv(file), { mode: 384, flag: "wx" });
1124
+ await writeFile(temp, text, { mode: 384, flag: "wx" });
1125
+ if (owner !== undefined)
1126
+ await keepOwner(temp, owner);
1015
1127
  await chmod(temp, mode);
1016
1128
  await rename(temp, target);
1017
1129
  } finally {
1018
1130
  await rm(temp, { force: true });
1019
1131
  }
1020
1132
  }
1021
- async function loadKnown(folder = process.cwd()) {
1022
- const pairs = [];
1023
- for (const name of await envFiles(folder)) {
1024
- for (const [key, value] of entries(parseDotenv(await Bun.file(`${folder}/${name}`).text()))) {
1025
- if (isSecretKey(key))
1026
- pairs.push([key, value]);
1027
- else
1028
- pairs.push(...urlPasswords(value).map((password) => [key, password]));
1133
+ async function keepOwner(path, owner) {
1134
+ try {
1135
+ await chown(path, owner.uid, owner.gid);
1136
+ } catch (error) {
1137
+ if (error.code !== "EPERM")
1138
+ throw error;
1139
+ }
1140
+ }
1141
+ async function writeEnv(path, file) {
1142
+ await writeAtomic(path, serializeDotenv(file));
1143
+ }
1144
+ async function withLocks(paths, fn) {
1145
+ const held = [];
1146
+ try {
1147
+ for (const path of [...new Set(paths)].sort())
1148
+ held.push(await acquire(path));
1149
+ return await fn();
1150
+ } finally {
1151
+ for (const lock of held)
1152
+ await rm(lock, { force: true });
1153
+ }
1154
+ }
1155
+ async function acquire(path) {
1156
+ let real = path;
1157
+ try {
1158
+ real = await realpath(path);
1159
+ } catch {}
1160
+ const lock = join(dirname(real), `.${basename(real)}.lock`);
1161
+ const deadline = Date.now() + LOCK_WAIT_MS;
1162
+ while (true) {
1163
+ try {
1164
+ await writeFile(lock, String(process.pid), { flag: "wx", mode: 384 });
1165
+ return lock;
1166
+ } catch (error) {
1167
+ if (error.code !== "EEXIST")
1168
+ throw error;
1029
1169
  }
1170
+ try {
1171
+ if (Date.now() - (await stat(lock)).mtimeMs > LOCK_STALE_MS)
1172
+ await rm(lock, { force: true });
1173
+ } catch {}
1174
+ if (Date.now() > deadline)
1175
+ fail(`${path} is locked by another ev run (${lock})`, 1);
1176
+ await Bun.sleep(25);
1177
+ }
1178
+ }
1179
+ function secretPairs(file) {
1180
+ const pairs = [];
1181
+ for (const [key, value] of entries(file)) {
1182
+ if (isSecretName(key))
1183
+ pairs.push([key, value]);
1184
+ else
1185
+ pairs.push(...urlPasswords(value).map((password) => [key, password]));
1186
+ }
1187
+ return pairs;
1188
+ }
1189
+ async function envFilesBelow(folder, depth = 0) {
1190
+ const found = [];
1191
+ let children;
1192
+ try {
1193
+ children = await readdir(folder, { withFileTypes: true });
1194
+ } catch {
1195
+ return found;
1196
+ }
1197
+ for (const entry of children) {
1198
+ const path = join(folder, entry.name);
1199
+ if (entry.isDirectory() && depth < KNOWN_DEPTH && !SKIPPED.has(entry.name))
1200
+ found.push(...await envFilesBelow(path, depth + 1));
1201
+ else if (ENV_FILE.test(entry.name) && await isFileEntry(entry, path))
1202
+ found.push(path);
1203
+ }
1204
+ return found;
1205
+ }
1206
+ async function loadKnown(folder = process.cwd(), extra = []) {
1207
+ const pairs = extra.flatMap(secretPairs);
1208
+ for (const path of await envFilesBelow(folder)) {
1209
+ try {
1210
+ pairs.push(...secretPairs(parseDotenv(await Bun.file(path).text())));
1211
+ } catch {}
1030
1212
  }
1031
1213
  return knownSecrets(pairs);
1032
1214
  }
@@ -1035,6 +1217,8 @@ async function loadKnown(folder = process.cwd()) {
1035
1217
  var DIFF_START = /^(?:diff --git |@@ )/;
1036
1218
  var NEW_FILE = /^\+\+\+ (?:b\/)?(.+)$/;
1037
1219
  var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)/;
1220
+ var MAX_PENDING = 1 << 18;
1221
+ var OVERLAP = 1 << 13;
1038
1222
 
1039
1223
  class Redactor {
1040
1224
  known;
@@ -1045,17 +1229,26 @@ class Redactor {
1045
1229
  this.known = known;
1046
1230
  }
1047
1231
  push(chunk) {
1048
- this.pending += chunk;
1049
- const end = this.pending.lastIndexOf(`
1232
+ const end = chunk.lastIndexOf(`
1050
1233
  `);
1234
+ this.pending += chunk;
1051
1235
  if (end === -1)
1052
- return "";
1053
- const complete = this.pending.slice(0, end + 1);
1054
- this.pending = this.pending.slice(end + 1);
1236
+ return this.pending.length > MAX_PENDING ? this.cut() : "";
1237
+ const split = this.pending.length - chunk.length + end + 1;
1238
+ const complete = this.pending.slice(0, split);
1239
+ this.pending = this.pending.slice(split);
1055
1240
  return complete.split(`
1056
1241
  `).slice(0, -1).map((line) => this.line(line)).filter((line) => line !== undefined).map((line) => `${line}
1057
1242
  `).join("");
1058
1243
  }
1244
+ cut() {
1245
+ const limit = this.pending.length - OVERLAP;
1246
+ const space = this.pending.lastIndexOf(" ", limit);
1247
+ const at = space > 0 ? space + 1 : limit;
1248
+ const head = this.pending.slice(0, at);
1249
+ this.pending = this.pending.slice(at);
1250
+ return this.line(head) ?? "";
1251
+ }
1059
1252
  end() {
1060
1253
  const rest = this.pending;
1061
1254
  this.pending = "";
@@ -1063,19 +1256,20 @@ class Redactor {
1063
1256
  }
1064
1257
  line(line) {
1065
1258
  if (this.inKey) {
1066
- if (PEM_END.test(line)) {
1067
- this.inKey = false;
1068
- return line;
1259
+ const end = PEM_END.exec(line);
1260
+ if (end === null) {
1261
+ if (this.hidden)
1262
+ return;
1263
+ this.hidden = true;
1264
+ return KEY_MASK;
1069
1265
  }
1070
- if (this.hidden)
1071
- return;
1072
- this.hidden = true;
1073
- return "*** private key redacted ***";
1266
+ this.inKey = false;
1267
+ return (end.index > 0 && !this.hidden ? KEY_MASK : "") + this.line(line.slice(end.index));
1074
1268
  }
1075
- if (PEM_BEGIN.test(line)) {
1076
- this.inKey = !PEM_END.test(line);
1077
- this.hidden = false;
1078
- return line;
1269
+ const body = openKeyBody(line);
1270
+ if (body !== undefined) {
1271
+ this.inKey = true;
1272
+ this.hidden = body !== "";
1079
1273
  }
1080
1274
  return maskLine(line, this.known);
1081
1275
  }
@@ -1115,15 +1309,21 @@ function check(text, known) {
1115
1309
  number = index + 1;
1116
1310
  }
1117
1311
  if (inKey) {
1118
- inKey = !PEM_END.test(line);
1119
- continue;
1312
+ const end = PEM_END.exec(line);
1313
+ inKey = end === null;
1314
+ if (end === null)
1315
+ continue;
1316
+ line = line.slice(end.index);
1120
1317
  }
1121
- if (PEM_BEGIN.test(line)) {
1122
- inKey = !PEM_END.test(line);
1123
- findings.push({ file, line: number, kind: "private key" });
1318
+ const found = findSecrets(line, known).map((match) => match.kind);
1319
+ const body = openKeyBody(line);
1320
+ if (body !== undefined) {
1321
+ inKey = true;
1322
+ if (body === "")
1323
+ found.push("private key");
1124
1324
  }
1125
- for (const match of findSecrets(line, known))
1126
- findings.push({ file, line: number, kind: match.kind });
1325
+ for (const kind of found)
1326
+ findings.push({ file, line: number, kind });
1127
1327
  }
1128
1328
  return findings;
1129
1329
  }
@@ -1131,7 +1331,7 @@ function check(text, known) {
1131
1331
  // src/redact.ts
1132
1332
  var usage = `redact [--check]
1133
1333
 
1134
- Copies stdin to stdout line by line with secrets masked: known token formats (ghp_, github_pat_, glpat-, sk-, xox?-, JWTs), private key blocks, the password and the tokens of any URL and the values of secret-looking keys in this folder's .env* files.
1334
+ Copies stdin to stdout line by line with secrets masked: known token formats (GitHub, GitLab, Slack, AWS, npm, Stripe, Google and SendGrid keys, sk-, JWTs, Authorization headers, password= and secret= pairs), private key blocks, the password and the tokens of any URL and the values of secret-looking keys in this folder's .env* files.
1135
1335
  --check prints nothing of the input and lists the file and line of each secret found, exiting 1 when there is any: \`git diff --cached | redact --check\`.
1136
1336
 
1137
1337
  Exits 0 when it copied the input or found no secret, 1 when --check finds one, 2 on a usage error.`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/env-tools",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "ev lists and edits .env files with secrets masked, redact masks secrets in any piped output.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
package/src/dotenv.ts CHANGED
@@ -13,7 +13,10 @@ const ASSIGNMENT = /^\s*(?:export\s+)?([A-Z_][\w.-]*)\s*=(.*)$/i
13
13
  const EXPORTED = /^\s*export\s/
14
14
  const INLINE_COMMENT = /\s+#.*$/
15
15
  const CARRIAGE_RETURN = /\r$/
16
- const ESCAPES: Record<string, string> = { 'n': '\n', 'r': '\r', 't': '\t', '"': '"', '\\': '\\' }
16
+ const ESCAPES: Record<string, string> = { 'n': '\n', 'r': '\r', 't': '\t', '"': '"', '\\': '\\', '$': '$' }
17
+ const LINE_BREAK = /[\r\n]/
18
+ // Bun does not unescape these inside double quotes, so a value holding one is written in single quotes.
19
+ const RAW_ONLY = /["\\]/
17
20
  const NEEDS_QUOTES = /[\s#"'\\$`]/
18
21
 
19
22
  // Finds the closing quote, skipping backslash escapes inside double quotes. Returns -1 when the value continues on the next line.
@@ -28,6 +31,11 @@ function closingQuote(text: string, quote: string): number {
28
31
  return -1
29
32
  }
30
33
 
34
+ // Bun reads `\$` as a literal dollar sign in every quoting, and expands a bare `$` even inside single quotes.
35
+ function unescapeDollar(text: string): string {
36
+ return text.replaceAll('\\$', '$')
37
+ }
38
+
31
39
  function unescapeDouble(text: string): string {
32
40
  let out = ''
33
41
 
@@ -65,7 +73,7 @@ export function parseDotenv(text: string): Dotenv {
65
73
  const quote = start[0]
66
74
 
67
75
  if (quote !== '"' && quote !== '\'' && quote !== '`') {
68
- lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, '').trim() })
76
+ lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, '').trim()) })
69
77
 
70
78
  continue
71
79
  }
@@ -83,7 +91,7 @@ export function parseDotenv(text: string): Dotenv {
83
91
 
84
92
  const inner = end === -1 ? body : body.slice(0, end)
85
93
 
86
- lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner })
94
+ lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) })
87
95
  }
88
96
 
89
97
  return { lines }
@@ -109,7 +117,11 @@ export function formatAssignment(key: string, value: string): string {
109
117
  if (value === '' || !NEEDS_QUOTES.test(value))
110
118
  return `${key}=${value}`
111
119
 
112
- const escaped = value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\r', '\\r').replaceAll('\n', '\\n')
120
+ // Bun expands a bare `$` in every quoting, so each one is written as `\$`.
121
+ if (RAW_ONLY.test(value) && !value.includes('\'') && !LINE_BREAK.test(value))
122
+ return `${key}='${value.replaceAll('$', '\\$')}'`
123
+
124
+ const escaped = value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\r', '\\r').replaceAll('\n', '\\n').replaceAll('$', '\\$')
113
125
 
114
126
  return `${key}="${escaped}"`
115
127
  }