@kaynooo/env-tools 0.6.0 → 0.7.1

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,24 @@ 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
+ }
787
+ // ../kit/src/sessions.ts
788
+ var {Glob } = globalThis.Bun;
789
+ var SESSION_FILES = new Glob("*.json");
790
+ var ACCOUNT_DIRS = new Glob("*/sessions");
758
791
  // package.json
759
- var version = "0.6.0";
792
+ var version = "0.7.1";
760
793
 
761
794
  // src/files.ts
762
- import { chmod, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
795
+ import { chmod, chown, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
763
796
  import { basename, dirname, join } from "path";
764
797
 
765
798
  // src/dotenv.ts
@@ -768,7 +801,9 @@ var EXPORTED = /^\s*export\s/;
768
801
  var INLINE_COMMENT = /\s+#.*$/;
769
802
  var CARRIAGE_RETURN = /\r$/;
770
803
  var ESCAPES = { n: `
771
- `, r: "\r", t: "\t", '"': '"', "\\": "\\" };
804
+ `, r: "\r", t: "\t", '"': '"', "\\": "\\", $: "$" };
805
+ var LINE_BREAK = /[\r\n]/;
806
+ var RAW_ONLY = /["\\]/;
772
807
  var NEEDS_QUOTES = /[\s#"'\\$`]/;
773
808
  function closingQuote(text, quote) {
774
809
  for (let index = 0;index < text.length; index++) {
@@ -779,6 +814,9 @@ function closingQuote(text, quote) {
779
814
  }
780
815
  return -1;
781
816
  }
817
+ function unescapeDollar(text) {
818
+ return text.replaceAll("\\$", "$");
819
+ }
782
820
  function unescapeDouble(text) {
783
821
  let out = "";
784
822
  for (let index = 0;index < text.length; index++) {
@@ -807,7 +845,7 @@ function parseDotenv(text) {
807
845
  const start = rest.trimStart();
808
846
  const quote = start[0];
809
847
  if (quote !== '"' && quote !== "'" && quote !== "`") {
810
- lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, "").trim() });
848
+ lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, "").trim()) });
811
849
  continue;
812
850
  }
813
851
  let body = start.slice(1);
@@ -822,7 +860,7 @@ ${physical[index] ?? ""}`;
822
860
  end = closingQuote(body, quote);
823
861
  }
824
862
  const inner = end === -1 ? body : body.slice(0, end);
825
- lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner });
863
+ lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) });
826
864
  }
827
865
  return { lines };
828
866
  }
@@ -841,8 +879,10 @@ function entries(file) {
841
879
  function formatAssignment(key, value) {
842
880
  if (value === "" || !NEEDS_QUOTES.test(value))
843
881
  return `${key}=${value}`;
882
+ if (RAW_ONLY.test(value) && !value.includes("'") && !LINE_BREAK.test(value))
883
+ return `${key}='${value.replaceAll("$", "\\$")}'`;
844
884
  const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("\r", "\\r").replaceAll(`
845
- `, "\\n");
885
+ `, "\\n").replaceAll("$", "\\$");
846
886
  return `${key}="${escaped}"`;
847
887
  }
848
888
  function setLine(file, key, value, raw) {
@@ -872,20 +912,38 @@ function lastLine(file, key) {
872
912
  }
873
913
 
874
914
  // 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;
915
+ var MIN_KNOWN_LENGTH = 4;
916
+ var KEY_MASK = "*** private key redacted ***";
917
+ var PLAIN_VALUE = /^(?:true|false|null|none|nil|yes|no|on|off|undefined)$/i;
918
+ var PLACEHOLDER = /^[$%{<(*/]/;
919
+ var URL_PASSWORD = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]*:([^\s'"]{1,1024})@/dgi;
920
+ var PORT = /^\d{1,5}$/;
878
921
  var ESCAPE2 = /[.*+?^${}()|[\]\\]/g;
922
+ function isPlaceholder(value) {
923
+ return PLACEHOLDER.test(value) || PLAIN_VALUE.test(value);
924
+ }
879
925
  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]
926
+ { kind: "GitHub token", pattern: /\b(?:gh[pousr]_|github_pat_)\w{20,}/dg },
927
+ { kind: "GitLab token", pattern: /\bglpat-[\w-]{20,}/dg },
928
+ { kind: "API key", pattern: /\bsk-[\w-]{20,}/dg },
929
+ { kind: "Slack token", pattern: /\bx(?:ox[abprs]|app)-[\w-]{10,}/dg },
930
+ { kind: "JWT", pattern: /\beyJ[\w-]+\.[\w-]+\.[\w-]+/dg },
931
+ { kind: "AWS access key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/dg },
932
+ { kind: "npm token", pattern: /\bnpm_[A-Za-z0-9]{30,}/dg },
933
+ { kind: "Stripe key", pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}/dg },
934
+ { kind: "Google API key", pattern: /\bAIza[\w-]{35}/dg },
935
+ { kind: "SendGrid key", pattern: /\bSG\.[\w-]{16,}\.[\w-]{16,}/dg },
936
+ { kind: "Authorization header", pattern: /\bAuthorization["']?\s*[:=]\s*["']?(?:Bearer|Basic|Token|Digest)\s+([^\s"',;]{8,})/dgi, skip: isPlaceholder },
937
+ { kind: "Bearer token", pattern: /\bBearer\s+((?=[\w.~+/=-]*\d)[\w.~+/=-]{20,})/dg },
938
+ { kind: "password", pattern: /(?<![\w-])(?:[\w-]{0,30}[_-])?(?:password|passwd|secret|api[_-]?key)(?:[_-][\w-]{0,30})?=([^\s"'&;,]{4,})/dgi, skip: isPlaceholder },
939
+ { 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
940
  ];
886
- var URL_PARTS = /\b[a-z][a-z0-9+.-]*:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
941
+ var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
942
+ var PEM_END = /-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
943
+ var PEM_BODY = /(?<=-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)(?:(?!-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)[\s\S])+/dg;
944
+ var PEM_BEGIN_ALL = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/g;
945
+ var URL_PARTS = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
887
946
  var PATH_SEGMENT = /[^/]+/g;
888
- var QUERY_PARAM = /([^&=]+)=([^&]*)/g;
889
947
  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
948
  var WEBHOOK_FORMATS = [
891
949
  ["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
@@ -894,28 +952,41 @@ var WEBHOOK_FORMATS = [
894
952
  ["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
895
953
  ];
896
954
  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
955
  function hint(value) {
903
956
  const shown = Math.min(3, Math.floor(value.length / 4));
904
957
  return `${value.slice(0, shown)}***(${value.length})`;
905
958
  }
959
+ function forms(value) {
960
+ return [...new Set([value, encodeURIComponent(value), JSON.stringify(value).slice(1, -1)])];
961
+ }
906
962
  function knownSecrets(pairs) {
907
963
  const keys = new Map;
908
964
  for (const [key, value] of pairs) {
909
- if (value.length >= MIN_KNOWN_LENGTH && !keys.has(value))
910
- keys.set(value, key);
965
+ if (value.length < MIN_KNOWN_LENGTH || PLAIN_VALUE.test(value))
966
+ continue;
967
+ for (const form of forms(value)) {
968
+ if (!keys.has(form))
969
+ keys.set(form, key);
970
+ }
911
971
  }
912
972
  if (keys.size === 0)
913
973
  return { pattern: undefined, keys };
914
974
  const alternatives = [...keys.keys()].sort((a, b) => b.length - a.length).map((value) => value.replace(ESCAPE2, "\\$&"));
915
975
  return { pattern: new RegExp(`(?<!\\w)(?:${alternatives.join("|")})(?!\\w)`, "g"), keys };
916
976
  }
977
+ function passwordSpans(text) {
978
+ const spans = [];
979
+ for (const url of text.matchAll(URL_PASSWORD)) {
980
+ const [start, end] = url.indices?.[1] ?? [];
981
+ const password = url[1] ?? "";
982
+ const slash = password.indexOf("/");
983
+ if (start !== undefined && end !== undefined && !(slash !== -1 && PORT.test(password.slice(0, slash))))
984
+ spans.push([start, end]);
985
+ }
986
+ return spans;
987
+ }
917
988
  function urlPasswords(text) {
918
- return [...text.matchAll(URL_PASSWORD)].flatMap((url) => url[1] ?? []);
989
+ return passwordSpans(text).map(([start, end]) => text.slice(start, end));
919
990
  }
920
991
  function urlTokens(line) {
921
992
  const found = [];
@@ -942,24 +1013,34 @@ function urlTokens(line) {
942
1013
  }
943
1014
  if (query === undefined || queryStart === undefined)
944
1015
  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);
1016
+ let offset = 0;
1017
+ for (const param of query.split("&")) {
1018
+ const equals = param.indexOf("=");
1019
+ if (equals > 0 && equals < param.length - 1 && SECRET_PARAM.test(param.slice(0, equals)))
1020
+ add("URL token", queryStart + offset + equals + 1, param.slice(equals + 1));
1021
+ offset += param.length + 1;
949
1022
  }
950
1023
  }
951
1024
  return found.filter((match) => match.end > match.start);
952
1025
  }
953
1026
  function findSecrets(line, known) {
954
1027
  const found = [];
955
- for (const url of line.matchAll(URL_PASSWORD)) {
956
- const [start, end] = url.indices?.[1] ?? [];
1028
+ for (const [start, end] of passwordSpans(line))
1029
+ found.push({ start, end, kind: "URL password", mask: "***" });
1030
+ for (const body of line.matchAll(PEM_BODY)) {
1031
+ const [start, end] = body.indices?.[0] ?? [];
957
1032
  if (start !== undefined && end !== undefined)
958
- found.push({ start, end, kind: "URL password", mask: "***" });
1033
+ found.push({ start, end, kind: "private key", mask: KEY_MASK });
959
1034
  }
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]) });
1035
+ for (const { kind, pattern, skip } of TOKEN_FORMATS) {
1036
+ for (const token of line.matchAll(pattern)) {
1037
+ const [start, end] = token.indices?.at(-1) ?? [];
1038
+ if (start === undefined || end === undefined)
1039
+ continue;
1040
+ const value = line.slice(start, end);
1041
+ if (!skip?.(value))
1042
+ found.push({ start, end, kind, mask: hint(value) });
1043
+ }
963
1044
  }
964
1045
  found.push(...urlTokens(line));
965
1046
  if (known?.pattern !== undefined) {
@@ -974,6 +1055,13 @@ function findSecrets(line, known) {
974
1055
  }
975
1056
  return kept;
976
1057
  }
1058
+ function openKeyBody(line) {
1059
+ const last = [...line.matchAll(PEM_BEGIN_ALL)].at(-1);
1060
+ if (last === undefined)
1061
+ return;
1062
+ const after = line.slice(last.index + last[0].length);
1063
+ return PEM_END.test(after) ? undefined : after.trim();
1064
+ }
977
1065
  function maskLine(line, known) {
978
1066
  let out = "";
979
1067
  let at = 0;
@@ -983,18 +1071,38 @@ function maskLine(line, known) {
983
1071
  }
984
1072
  return out + line.slice(at);
985
1073
  }
986
- function maskValue(key, value) {
1074
+ function maskValue(key, value, known) {
987
1075
  if (value === "")
988
1076
  return value;
989
- return isSecretKey(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value);
1077
+ return isSecretName(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value, known);
990
1078
  }
991
1079
 
992
1080
  // src/files.ts
993
- var ENV_FILE = /^\.env(?:\..+)?$/;
1081
+ var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
994
1082
  var TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/;
1083
+ var KNOWN_DEPTH = 3;
1084
+ var SKIPPED = new Set(["node_modules", ".git", "vendor"]);
1085
+ var LOCK_WAIT_MS = 5000;
1086
+ var LOCK_STALE_MS = 30000;
1087
+ async function isFileEntry(entry, path) {
1088
+ if (entry.isFile())
1089
+ return true;
1090
+ if (!entry.isSymbolicLink())
1091
+ return false;
1092
+ try {
1093
+ return (await stat(path)).isFile();
1094
+ } catch {
1095
+ return false;
1096
+ }
1097
+ }
995
1098
  async function envFiles(folder = process.cwd()) {
996
1099
  const found = await readdir(folder, { withFileTypes: true });
997
- return found.filter((entry) => entry.isFile() && ENV_FILE.test(entry.name)).map((entry) => entry.name).sort();
1100
+ const names = [];
1101
+ for (const entry of found) {
1102
+ if (ENV_FILE.test(entry.name) && await isFileEntry(entry, join(folder, entry.name)))
1103
+ names.push(entry.name);
1104
+ }
1105
+ return names.sort();
998
1106
  }
999
1107
  async function readEnv(path) {
1000
1108
  const file = Bun.file(path);
@@ -1002,31 +1110,109 @@ async function readEnv(path) {
1002
1110
  fail(`${path} does not exist`);
1003
1111
  return parseDotenv(await file.text());
1004
1112
  }
1005
- async function writeEnv(path, file) {
1113
+ async function writeAtomic(path, text) {
1006
1114
  let target = path;
1007
1115
  let mode = 384;
1116
+ let owner;
1008
1117
  try {
1009
1118
  target = await realpath(path);
1010
- mode = (await stat(target)).mode & 511;
1011
- } catch {}
1119
+ const current = await stat(target);
1120
+ mode = current.mode & 511;
1121
+ owner = current;
1122
+ } catch (error) {
1123
+ if (error.code !== "ENOENT")
1124
+ throw error;
1125
+ }
1012
1126
  const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
1013
1127
  try {
1014
- await writeFile(temp, serializeDotenv(file), { mode: 384, flag: "wx" });
1128
+ await writeFile(temp, text, { mode: 384, flag: "wx" });
1129
+ if (owner !== undefined)
1130
+ await keepOwner(temp, owner);
1015
1131
  await chmod(temp, mode);
1016
1132
  await rename(temp, target);
1017
1133
  } finally {
1018
1134
  await rm(temp, { force: true });
1019
1135
  }
1020
1136
  }
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]));
1137
+ async function keepOwner(path, owner) {
1138
+ try {
1139
+ await chown(path, owner.uid, owner.gid);
1140
+ } catch (error) {
1141
+ if (error.code !== "EPERM")
1142
+ throw error;
1143
+ }
1144
+ }
1145
+ async function writeEnv(path, file) {
1146
+ await writeAtomic(path, serializeDotenv(file));
1147
+ }
1148
+ async function withLocks(paths, fn) {
1149
+ const held = [];
1150
+ try {
1151
+ for (const path of [...new Set(paths)].sort())
1152
+ held.push(await acquire(path));
1153
+ return await fn();
1154
+ } finally {
1155
+ for (const lock of held)
1156
+ await rm(lock, { force: true });
1157
+ }
1158
+ }
1159
+ async function acquire(path) {
1160
+ let real = path;
1161
+ try {
1162
+ real = await realpath(path);
1163
+ } catch {}
1164
+ const lock = join(dirname(real), `.${basename(real)}.lock`);
1165
+ const deadline = Date.now() + LOCK_WAIT_MS;
1166
+ while (true) {
1167
+ try {
1168
+ await writeFile(lock, String(process.pid), { flag: "wx", mode: 384 });
1169
+ return lock;
1170
+ } catch (error) {
1171
+ if (error.code !== "EEXIST")
1172
+ throw error;
1029
1173
  }
1174
+ try {
1175
+ if (Date.now() - (await stat(lock)).mtimeMs > LOCK_STALE_MS)
1176
+ await rm(lock, { force: true });
1177
+ } catch {}
1178
+ if (Date.now() > deadline)
1179
+ fail(`${path} is locked by another ev run (${lock})`, 1);
1180
+ await Bun.sleep(25);
1181
+ }
1182
+ }
1183
+ function secretPairs(file) {
1184
+ const pairs = [];
1185
+ for (const [key, value] of entries(file)) {
1186
+ if (isSecretName(key))
1187
+ pairs.push([key, value]);
1188
+ else
1189
+ pairs.push(...urlPasswords(value).map((password) => [key, password]));
1190
+ }
1191
+ return pairs;
1192
+ }
1193
+ async function envFilesBelow(folder, depth = 0) {
1194
+ const found = [];
1195
+ let children;
1196
+ try {
1197
+ children = await readdir(folder, { withFileTypes: true });
1198
+ } catch {
1199
+ return found;
1200
+ }
1201
+ for (const entry of children) {
1202
+ const path = join(folder, entry.name);
1203
+ if (entry.isDirectory() && depth < KNOWN_DEPTH && !SKIPPED.has(entry.name))
1204
+ found.push(...await envFilesBelow(path, depth + 1));
1205
+ else if (ENV_FILE.test(entry.name) && await isFileEntry(entry, path))
1206
+ found.push(path);
1207
+ }
1208
+ return found;
1209
+ }
1210
+ async function loadKnown(folder = process.cwd(), extra = []) {
1211
+ const pairs = extra.flatMap(secretPairs);
1212
+ for (const path of await envFilesBelow(folder)) {
1213
+ try {
1214
+ pairs.push(...secretPairs(parseDotenv(await Bun.file(path).text())));
1215
+ } catch {}
1030
1216
  }
1031
1217
  return knownSecrets(pairs);
1032
1218
  }
@@ -1035,6 +1221,8 @@ async function loadKnown(folder = process.cwd()) {
1035
1221
  var DIFF_START = /^(?:diff --git |@@ )/;
1036
1222
  var NEW_FILE = /^\+\+\+ (?:b\/)?(.+)$/;
1037
1223
  var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)/;
1224
+ var MAX_PENDING = 1 << 18;
1225
+ var OVERLAP = 1 << 13;
1038
1226
 
1039
1227
  class Redactor {
1040
1228
  known;
@@ -1045,17 +1233,26 @@ class Redactor {
1045
1233
  this.known = known;
1046
1234
  }
1047
1235
  push(chunk) {
1048
- this.pending += chunk;
1049
- const end = this.pending.lastIndexOf(`
1236
+ const end = chunk.lastIndexOf(`
1050
1237
  `);
1238
+ this.pending += chunk;
1051
1239
  if (end === -1)
1052
- return "";
1053
- const complete = this.pending.slice(0, end + 1);
1054
- this.pending = this.pending.slice(end + 1);
1240
+ return this.pending.length > MAX_PENDING ? this.cut() : "";
1241
+ const split = this.pending.length - chunk.length + end + 1;
1242
+ const complete = this.pending.slice(0, split);
1243
+ this.pending = this.pending.slice(split);
1055
1244
  return complete.split(`
1056
1245
  `).slice(0, -1).map((line) => this.line(line)).filter((line) => line !== undefined).map((line) => `${line}
1057
1246
  `).join("");
1058
1247
  }
1248
+ cut() {
1249
+ const limit = this.pending.length - OVERLAP;
1250
+ const space = this.pending.lastIndexOf(" ", limit);
1251
+ const at = space > 0 ? space + 1 : limit;
1252
+ const head = this.pending.slice(0, at);
1253
+ this.pending = this.pending.slice(at);
1254
+ return this.line(head) ?? "";
1255
+ }
1059
1256
  end() {
1060
1257
  const rest = this.pending;
1061
1258
  this.pending = "";
@@ -1063,19 +1260,20 @@ class Redactor {
1063
1260
  }
1064
1261
  line(line) {
1065
1262
  if (this.inKey) {
1066
- if (PEM_END.test(line)) {
1067
- this.inKey = false;
1068
- return line;
1263
+ const end = PEM_END.exec(line);
1264
+ if (end === null) {
1265
+ if (this.hidden)
1266
+ return;
1267
+ this.hidden = true;
1268
+ return KEY_MASK;
1069
1269
  }
1070
- if (this.hidden)
1071
- return;
1072
- this.hidden = true;
1073
- return "*** private key redacted ***";
1270
+ this.inKey = false;
1271
+ return (end.index > 0 && !this.hidden ? KEY_MASK : "") + this.line(line.slice(end.index));
1074
1272
  }
1075
- if (PEM_BEGIN.test(line)) {
1076
- this.inKey = !PEM_END.test(line);
1077
- this.hidden = false;
1078
- return line;
1273
+ const body = openKeyBody(line);
1274
+ if (body !== undefined) {
1275
+ this.inKey = true;
1276
+ this.hidden = body !== "";
1079
1277
  }
1080
1278
  return maskLine(line, this.known);
1081
1279
  }
@@ -1115,15 +1313,21 @@ function check(text, known) {
1115
1313
  number = index + 1;
1116
1314
  }
1117
1315
  if (inKey) {
1118
- inKey = !PEM_END.test(line);
1119
- continue;
1316
+ const end = PEM_END.exec(line);
1317
+ inKey = end === null;
1318
+ if (end === null)
1319
+ continue;
1320
+ line = line.slice(end.index);
1120
1321
  }
1121
- if (PEM_BEGIN.test(line)) {
1122
- inKey = !PEM_END.test(line);
1123
- findings.push({ file, line: number, kind: "private key" });
1322
+ const found = findSecrets(line, known).map((match) => match.kind);
1323
+ const body = openKeyBody(line);
1324
+ if (body !== undefined) {
1325
+ inKey = true;
1326
+ if (body === "")
1327
+ found.push("private key");
1124
1328
  }
1125
- for (const match of findSecrets(line, known))
1126
- findings.push({ file, line: number, kind: match.kind });
1329
+ for (const kind of found)
1330
+ findings.push({ file, line: number, kind });
1127
1331
  }
1128
1332
  return findings;
1129
1333
  }
@@ -1131,7 +1335,7 @@ function check(text, known) {
1131
1335
  // src/redact.ts
1132
1336
  var usage = `redact [--check]
1133
1337
 
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.
1338
+ 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
1339
  --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
1340
 
1137
1341
  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.1",
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
  }