@kaynooo/env-tools 0.5.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 },
@@ -343,6 +358,26 @@ function printJson(value) {
343
358
  console.log(JSON.stringify(value, undefined, 2));
344
359
  }
345
360
 
361
+ // ../kit/src/text.ts
362
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
363
+ function distance(a, b) {
364
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
365
+ for (const [i, left] of [...a].entries()) {
366
+ const current = [i + 1];
367
+ for (const [j, right] of [...b].entries())
368
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
369
+ previous = current;
370
+ }
371
+ return previous[b.length] ?? 0;
372
+ }
373
+ function closest(name, names) {
374
+ const started = names.find((known) => known.startsWith(name));
375
+ if (started !== undefined)
376
+ return started;
377
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
378
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
379
+ }
380
+
346
381
  // ../kit/src/args.ts
347
382
  function jsonOption(holds) {
348
383
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -362,8 +397,8 @@ function fail(message, code = 2, hint) {
362
397
  }
363
398
 
364
399
  class UsageError extends Exit {
365
- constructor(message) {
366
- super(message, 2);
400
+ constructor(message, hint) {
401
+ super(message, 2, hint);
367
402
  }
368
403
  }
369
404
  function badUsage(message) {
@@ -386,6 +421,7 @@ var completion = { active: false };
386
421
  var OPTIONAL = /\[[^\]]+\]/g;
387
422
  var USAGE_PREFIX = /^usage:\s*/i;
388
423
  var FULL_STOP = /\.$/;
424
+ var DASHES = /^-+/;
389
425
  function usageLine(usage, colors = paint(process4.stdout)) {
390
426
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
391
427
  }
@@ -422,10 +458,10 @@ function optionRows(options, colors = paint(process4.stdout)) {
422
458
  const rows = Object.entries(options).map(([name, option]) => {
423
459
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
424
460
  const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
425
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
461
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
426
462
  });
427
463
  const width = Math.max(0, ...rows.map((row) => row.width));
428
- return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
464
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
429
465
  }
430
466
  var OUTPUT_OPTIONS = {
431
467
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
@@ -457,7 +493,8 @@ function parse(args, usage, options, settings = {}) {
457
493
  try {
458
494
  parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
459
495
  } catch (error) {
460
- badUsage(parseError(error));
496
+ const message = parseError(error);
497
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
461
498
  }
462
499
  const values = parsed.values;
463
500
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
@@ -500,6 +537,22 @@ function parseError(error) {
500
537
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
501
538
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
502
539
  }
540
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
541
+ function nearestOption(message, options, allowNegative) {
542
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
543
+ if (typed === undefined)
544
+ return;
545
+ const word = typed.replace(DASHES, "");
546
+ if (word.length < 2 && !typed.startsWith("--"))
547
+ return;
548
+ const known = Object.entries(options).flatMap(([name, option]) => [
549
+ ...option.short === undefined ? [] : [`-${option.short}`],
550
+ `--${name}`,
551
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
552
+ ]);
553
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
554
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
555
+ }
503
556
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
504
557
  if (path === "-") {
505
558
  if (process4.stdin.isTTY)
@@ -511,35 +564,26 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
511
564
  fail(`no file ${path}`);
512
565
  return file.text();
513
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}\\`;
514
572
  // ../kit/src/cli.ts
515
573
  import process5 from "process";
516
-
517
- // ../kit/src/text.ts
518
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
519
- function distance(a, b) {
520
- let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
521
- for (const [i, left] of [...a].entries()) {
522
- const current = [i + 1];
523
- for (const [j, right] of [...b].entries())
524
- current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
525
- previous = current;
526
- }
527
- return previous[b.length] ?? 0;
528
- }
529
-
530
- // ../kit/src/cli.ts
531
574
  var LEVEL_OPTIONS = {
532
575
  ...OUTPUT_OPTIONS,
533
576
  help: { type: "boolean", short: "h", description: "show this help" }
534
577
  };
535
578
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
536
- function commandRows(commands, colors) {
579
+ async function commandRows(commands, colors) {
537
580
  const width = Math.max(...Object.keys(commands).map((name) => name.length));
538
581
  const groups = new Map([["Commands:", []]]);
539
- for (const [name, { summary, group }] of Object.entries(commands)) {
582
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
583
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
540
584
  const heading = group === undefined ? "Commands:" : `${group}:`;
541
585
  const rows = groups.get(heading) ?? [];
542
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
586
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
543
587
  groups.set(heading, rows);
544
588
  }
545
589
  return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
@@ -559,28 +603,38 @@ function takeOutputFlags(argv) {
559
603
  }
560
604
  return argv.slice(index);
561
605
  }
562
- function closest(name, names) {
563
- const started = names.find((known) => known.startsWith(name));
564
- if (started !== undefined)
565
- return started;
566
- const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
567
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
606
+ async function resolveSummary(summary) {
607
+ return typeof summary === "string" ? summary : summary();
568
608
  }
569
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
609
+ function scopeOf(commands, extra, aliases) {
610
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
611
+ }
612
+ async function expand(scope, words) {
613
+ if (scope.lazy.length === 0)
614
+ return scope;
615
+ const commands = { ...scope.commands };
616
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
617
+ for (const [name, command] of Object.entries(added))
618
+ commands[name] ??= command;
619
+ }
620
+ return { ...scope, commands, lazy: [] };
621
+ }
622
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
570
623
  const colors = paint(stream);
571
624
  const prefix = path.join(" ");
625
+ const summary = await resolveSummary(lazySummary);
572
626
  if (wantsHelpJson())
573
627
  return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
574
628
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
575
629
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
576
630
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
577
631
  return [
578
- header(colors),
632
+ header(colors, summary),
579
633
  "",
580
634
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
581
635
  ...replacesRows(replaces, colors),
582
636
  "",
583
- ...commandRows(commands, colors),
637
+ ...await commandRows(commands, colors),
584
638
  ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
585
639
  "",
586
640
  colors.bold("Options:"),
@@ -626,11 +680,11 @@ async function dispatch(level, typed, { version, redact }) {
626
680
  }
627
681
  const [name, ...args] = argv;
628
682
  if (name === undefined && level.path.length > 1) {
629
- console.error(await help(level, process5.stderr));
683
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stderr));
630
684
  return 2;
631
685
  }
632
686
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
633
- console.log(await help(level, process5.stdout));
687
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stdout));
634
688
  return 0;
635
689
  }
636
690
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -639,6 +693,10 @@ async function dispatch(level, typed, { version, redact }) {
639
693
  }
640
694
  let command = level.commands[level.aliases?.[name] ?? name];
641
695
  let rest = args;
696
+ if (command === undefined && level.lazy.length > 0) {
697
+ level = await expand(level, [...level.path.slice(1), ...argv]);
698
+ command = level.commands[level.aliases?.[name] ?? name];
699
+ }
642
700
  if (command === undefined && level.defaultCommand !== undefined) {
643
701
  command = level.commands[level.defaultCommand];
644
702
  rest = argv;
@@ -650,7 +708,7 @@ async function dispatch(level, typed, { version, redact }) {
650
708
  }
651
709
  if ("commands" in command) {
652
710
  const { summary } = command;
653
- return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, summary, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
711
+ return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
654
712
  }
655
713
  try {
656
714
  return await command.run(rest);
@@ -675,18 +733,24 @@ async function optionsOf(run) {
675
733
  async function leafCandidates(run, words) {
676
734
  if (!(words.at(-1) ?? "").startsWith("-"))
677
735
  return [FILES];
678
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
736
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
679
737
  }
680
- async function candidates(commands, aliases, defaultCommand, words) {
738
+ async function candidates(scope, defaultCommand, words, before = []) {
681
739
  const [word = "", ...rest] = words;
682
- const command = commands[aliases?.[word] ?? word];
683
- if (rest.length === 0 && !word.startsWith("-"))
684
- return Object.entries(commands).map(([name, { summary }]) => `${name} ${summary}`);
740
+ const listing = rest.length === 0 && !word.startsWith("-");
741
+ let { commands, aliases } = scope;
742
+ let command = commands[aliases?.[word] ?? word];
743
+ if (listing || command === undefined) {
744
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
745
+ command = commands[aliases?.[word] ?? word];
746
+ }
747
+ if (listing)
748
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
685
749
  if (command === undefined) {
686
750
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
687
751
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
688
752
  }
689
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
753
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
690
754
  }
691
755
  async function printCandidates(found) {
692
756
  for (const line of await found)
@@ -695,8 +759,8 @@ async function printCandidates(found) {
695
759
  }
696
760
  async function runCli(cli, argv = process5.argv.slice(2)) {
697
761
  if (argv[0] === COMPLETE)
698
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
699
- return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, summary: cli.about, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
762
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
763
+ return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
700
764
  }
701
765
  async function runMain({ name, version, run, redact }, argv = process5.argv.slice(2)) {
702
766
  if (argv[0] === COMPLETE)
@@ -711,11 +775,21 @@ async function runMain({ name, version, run, redact }, argv = process5.argv.slic
711
775
  return reportThrown(name, error, redact, wantsJson(argv));
712
776
  }
713
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
+ }
714
787
  // package.json
715
- var version = "0.5.0";
788
+ var version = "0.7.0";
716
789
 
717
790
  // src/files.ts
718
- import { readdir } from "fs/promises";
791
+ import { chmod, chown, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
792
+ import { basename, dirname, join } from "path";
719
793
 
720
794
  // src/dotenv.ts
721
795
  var ASSIGNMENT = /^\s*(?:export\s+)?([A-Z_][\w.-]*)\s*=(.*)$/i;
@@ -723,7 +797,9 @@ var EXPORTED = /^\s*export\s/;
723
797
  var INLINE_COMMENT = /\s+#.*$/;
724
798
  var CARRIAGE_RETURN = /\r$/;
725
799
  var ESCAPES = { n: `
726
- `, r: "\r", t: "\t", '"': '"', "\\": "\\" };
800
+ `, r: "\r", t: "\t", '"': '"', "\\": "\\", $: "$" };
801
+ var LINE_BREAK = /[\r\n]/;
802
+ var RAW_ONLY = /["\\]/;
727
803
  var NEEDS_QUOTES = /[\s#"'\\$`]/;
728
804
  function closingQuote(text, quote) {
729
805
  for (let index = 0;index < text.length; index++) {
@@ -734,6 +810,9 @@ function closingQuote(text, quote) {
734
810
  }
735
811
  return -1;
736
812
  }
813
+ function unescapeDollar(text) {
814
+ return text.replaceAll("\\$", "$");
815
+ }
737
816
  function unescapeDouble(text) {
738
817
  let out = "";
739
818
  for (let index = 0;index < text.length; index++) {
@@ -762,7 +841,7 @@ function parseDotenv(text) {
762
841
  const start = rest.trimStart();
763
842
  const quote = start[0];
764
843
  if (quote !== '"' && quote !== "'" && quote !== "`") {
765
- lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, "").trim() });
844
+ lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, "").trim()) });
766
845
  continue;
767
846
  }
768
847
  let body = start.slice(1);
@@ -777,7 +856,7 @@ ${physical[index] ?? ""}`;
777
856
  end = closingQuote(body, quote);
778
857
  }
779
858
  const inner = end === -1 ? body : body.slice(0, end);
780
- lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner });
859
+ lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) });
781
860
  }
782
861
  return { lines };
783
862
  }
@@ -796,8 +875,10 @@ function entries(file) {
796
875
  function formatAssignment(key, value) {
797
876
  if (value === "" || !NEEDS_QUOTES.test(value))
798
877
  return `${key}=${value}`;
878
+ if (RAW_ONLY.test(value) && !value.includes("'") && !LINE_BREAK.test(value))
879
+ return `${key}='${value.replaceAll("$", "\\$")}'`;
799
880
  const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("\r", "\\r").replaceAll(`
800
- `, "\\n");
881
+ `, "\\n").replaceAll("$", "\\$");
801
882
  return `${key}="${escaped}"`;
802
883
  }
803
884
  function setLine(file, key, value, raw) {
@@ -827,51 +908,137 @@ function lastLine(file, key) {
827
908
  }
828
909
 
829
910
  // src/mask.ts
830
- var MIN_KNOWN_LENGTH = 6;
831
- var SECRET_KEY = /TOKEN|SECRET|PASSWORD|KEY|PRIVATE|DSN/i;
832
- 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}$/;
833
917
  var ESCAPE2 = /[.*+?^${}()|[\]\\]/g;
918
+ function isPlaceholder(value) {
919
+ return PLACEHOLDER.test(value) || PLAIN_VALUE.test(value);
920
+ }
834
921
  var TOKEN_FORMATS = [
835
- ["GitHub token", /\b(?:gh[pousr]_|github_pat_)\w{20,}/g],
836
- ["GitLab token", /\bglpat-[\w-]{20,}/g],
837
- ["API key", /\bsk-[\w-]{20,}/g],
838
- ["Slack token", /\bxox[abprs]-[\w-]{10,}/g],
839
- ["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 }
840
936
  ];
841
- var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
842
- var PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
843
- function isSecretKey(key) {
844
- return SECRET_KEY.test(key);
845
- }
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;
942
+ var PATH_SEGMENT = /[^/]+/g;
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;
944
+ var WEBHOOK_FORMATS = [
945
+ ["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
946
+ ["Slack webhook", /^hooks\.slack\.com\/(?:services|workflows)\/(?:[\w-]+\/)*([\w-]+)/di],
947
+ ["Uptime Kuma push URL", /\/api\/push\/([\w-]+)/di],
948
+ ["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
949
+ ];
950
+ var OPAQUE_SEGMENT = /^(?:(?=[\w-]{20,}$)(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w-]+|(?=[A-Za-z\d]{32,}$)(?=.*[A-Za-z])(?=.*\d).+)$/;
846
951
  function hint(value) {
847
952
  const shown = Math.min(3, Math.floor(value.length / 4));
848
953
  return `${value.slice(0, shown)}***(${value.length})`;
849
954
  }
955
+ function forms(value) {
956
+ return [...new Set([value, encodeURIComponent(value), JSON.stringify(value).slice(1, -1)])];
957
+ }
850
958
  function knownSecrets(pairs) {
851
959
  const keys = new Map;
852
960
  for (const [key, value] of pairs) {
853
- if (value.length >= MIN_KNOWN_LENGTH && !keys.has(value))
854
- 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
+ }
855
967
  }
856
968
  if (keys.size === 0)
857
969
  return { pattern: undefined, keys };
858
970
  const alternatives = [...keys.keys()].sort((a, b) => b.length - a.length).map((value) => value.replace(ESCAPE2, "\\$&"));
859
971
  return { pattern: new RegExp(`(?<!\\w)(?:${alternatives.join("|")})(?!\\w)`, "g"), keys };
860
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
+ }
861
984
  function urlPasswords(text) {
862
- return [...text.matchAll(URL_PASSWORD)].flatMap((url) => url[1] ?? []);
985
+ return passwordSpans(text).map(([start, end]) => text.slice(start, end));
986
+ }
987
+ function urlTokens(line) {
988
+ const found = [];
989
+ const add = (kind, start, value) => {
990
+ found.push({ start, end: start + value.length, kind, mask: hint(value) });
991
+ };
992
+ for (const url of line.matchAll(URL_PARTS)) {
993
+ const [authority, path = "", query] = [url[1] ?? "", url[2] ?? "", url[3]];
994
+ const authorityStart = url.indices?.[1]?.[0];
995
+ const pathStart = url.indices?.[2]?.[0];
996
+ const queryStart = url.indices?.[3]?.[0];
997
+ if (authorityStart === undefined || pathStart === undefined)
998
+ continue;
999
+ const hostAt = authority.lastIndexOf("@") + 1;
1000
+ const target = authority.slice(hostAt) + path;
1001
+ for (const [kind, pattern] of WEBHOOK_FORMATS) {
1002
+ const [start, end] = pattern.exec(target)?.indices?.[1] ?? [];
1003
+ if (start !== undefined && end !== undefined)
1004
+ add(kind, authorityStart + hostAt + start, target.slice(start, end));
1005
+ }
1006
+ for (const segment of path.matchAll(PATH_SEGMENT)) {
1007
+ if (OPAQUE_SEGMENT.test(segment[0]))
1008
+ add("URL token", pathStart + segment.index, segment[0]);
1009
+ }
1010
+ if (query === undefined || queryStart === undefined)
1011
+ continue;
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;
1018
+ }
1019
+ }
1020
+ return found.filter((match) => match.end > match.start);
863
1021
  }
864
1022
  function findSecrets(line, known) {
865
1023
  const found = [];
866
- for (const url of line.matchAll(URL_PASSWORD)) {
867
- 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] ?? [];
868
1028
  if (start !== undefined && end !== undefined)
869
- found.push({ start, end, kind: "URL password", mask: "***" });
1029
+ found.push({ start, end, kind: "private key", mask: KEY_MASK });
870
1030
  }
871
- for (const [kind, pattern] of TOKEN_FORMATS) {
872
- for (const token of line.matchAll(pattern))
873
- 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
+ }
874
1040
  }
1041
+ found.push(...urlTokens(line));
875
1042
  if (known?.pattern !== undefined) {
876
1043
  for (const value of line.matchAll(known.pattern))
877
1044
  found.push({ start: value.index, end: value.index + value[0].length, kind: `value of ${known.keys.get(value[0])}`, mask: hint(value[0]) });
@@ -884,6 +1051,13 @@ function findSecrets(line, known) {
884
1051
  }
885
1052
  return kept;
886
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
+ }
887
1061
  function maskLine(line, known) {
888
1062
  let out = "";
889
1063
  let at = 0;
@@ -893,18 +1067,38 @@ function maskLine(line, known) {
893
1067
  }
894
1068
  return out + line.slice(at);
895
1069
  }
896
- function maskValue(key, value) {
1070
+ function maskValue(key, value, known) {
897
1071
  if (value === "")
898
1072
  return value;
899
- return isSecretKey(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value);
1073
+ return isSecretName(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value, known);
900
1074
  }
901
1075
 
902
1076
  // src/files.ts
903
- var ENV_FILE = /^\.env(?:\..+)?$/;
1077
+ var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
904
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
+ }
905
1094
  async function envFiles(folder = process.cwd()) {
906
1095
  const found = await readdir(folder, { withFileTypes: true });
907
- 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();
908
1102
  }
909
1103
  async function readEnv(path) {
910
1104
  const file = Bun.file(path);
@@ -912,18 +1106,109 @@ async function readEnv(path) {
912
1106
  fail(`${path} does not exist`);
913
1107
  return parseDotenv(await file.text());
914
1108
  }
1109
+ async function writeAtomic(path, text) {
1110
+ let target = path;
1111
+ let mode = 384;
1112
+ let owner;
1113
+ try {
1114
+ target = await realpath(path);
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
+ }
1122
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
1123
+ try {
1124
+ await writeFile(temp, text, { mode: 384, flag: "wx" });
1125
+ if (owner !== undefined)
1126
+ await keepOwner(temp, owner);
1127
+ await chmod(temp, mode);
1128
+ await rename(temp, target);
1129
+ } finally {
1130
+ await rm(temp, { force: true });
1131
+ }
1132
+ }
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
+ }
915
1141
  async function writeEnv(path, file) {
916
- await Bun.write(path, serializeDotenv(file));
1142
+ await writeAtomic(path, serializeDotenv(file));
917
1143
  }
918
- async function loadKnown(folder = process.cwd()) {
919
- const pairs = [];
920
- for (const name of await envFiles(folder)) {
921
- for (const [key, value] of entries(parseDotenv(await Bun.file(`${folder}/${name}`).text()))) {
922
- if (isSecretKey(key))
923
- pairs.push([key, value]);
924
- else
925
- pairs.push(...urlPasswords(value).map((password) => [key, password]));
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;
926
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 {}
927
1212
  }
928
1213
  return knownSecrets(pairs);
929
1214
  }
@@ -932,6 +1217,8 @@ async function loadKnown(folder = process.cwd()) {
932
1217
  var DIFF_START = /^(?:diff --git |@@ )/;
933
1218
  var NEW_FILE = /^\+\+\+ (?:b\/)?(.+)$/;
934
1219
  var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)/;
1220
+ var MAX_PENDING = 1 << 18;
1221
+ var OVERLAP = 1 << 13;
935
1222
 
936
1223
  class Redactor {
937
1224
  known;
@@ -942,17 +1229,26 @@ class Redactor {
942
1229
  this.known = known;
943
1230
  }
944
1231
  push(chunk) {
945
- this.pending += chunk;
946
- const end = this.pending.lastIndexOf(`
1232
+ const end = chunk.lastIndexOf(`
947
1233
  `);
1234
+ this.pending += chunk;
948
1235
  if (end === -1)
949
- return "";
950
- const complete = this.pending.slice(0, end + 1);
951
- 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);
952
1240
  return complete.split(`
953
1241
  `).slice(0, -1).map((line) => this.line(line)).filter((line) => line !== undefined).map((line) => `${line}
954
1242
  `).join("");
955
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
+ }
956
1252
  end() {
957
1253
  const rest = this.pending;
958
1254
  this.pending = "";
@@ -960,19 +1256,20 @@ class Redactor {
960
1256
  }
961
1257
  line(line) {
962
1258
  if (this.inKey) {
963
- if (PEM_END.test(line)) {
964
- this.inKey = false;
965
- 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;
966
1265
  }
967
- if (this.hidden)
968
- return;
969
- this.hidden = true;
970
- return "*** private key redacted ***";
1266
+ this.inKey = false;
1267
+ return (end.index > 0 && !this.hidden ? KEY_MASK : "") + this.line(line.slice(end.index));
971
1268
  }
972
- if (PEM_BEGIN.test(line)) {
973
- this.inKey = !PEM_END.test(line);
974
- this.hidden = false;
975
- return line;
1269
+ const body = openKeyBody(line);
1270
+ if (body !== undefined) {
1271
+ this.inKey = true;
1272
+ this.hidden = body !== "";
976
1273
  }
977
1274
  return maskLine(line, this.known);
978
1275
  }
@@ -1012,15 +1309,21 @@ function check(text, known) {
1012
1309
  number = index + 1;
1013
1310
  }
1014
1311
  if (inKey) {
1015
- inKey = !PEM_END.test(line);
1016
- continue;
1312
+ const end = PEM_END.exec(line);
1313
+ inKey = end === null;
1314
+ if (end === null)
1315
+ continue;
1316
+ line = line.slice(end.index);
1017
1317
  }
1018
- if (PEM_BEGIN.test(line)) {
1019
- inKey = !PEM_END.test(line);
1020
- 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");
1021
1324
  }
1022
- for (const match of findSecrets(line, known))
1023
- findings.push({ file, line: number, kind: match.kind });
1325
+ for (const kind of found)
1326
+ findings.push({ file, line: number, kind });
1024
1327
  }
1025
1328
  return findings;
1026
1329
  }
@@ -1028,7 +1331,7 @@ function check(text, known) {
1028
1331
  // src/redact.ts
1029
1332
  var usage = `redact [--check]
1030
1333
 
1031
- 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 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.
1032
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\`.
1033
1336
 
1034
1337
  Exits 0 when it copied the input or found no secret, 1 when --check finds one, 2 on a usage error.`;