@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/README.md +3 -2
- package/dist/ev.js +601 -106
- package/dist/redact.js +274 -74
- package/package.json +1 -1
- package/src/dotenv.ts +16 -4
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.
|
|
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 =
|
|
876
|
-
var
|
|
877
|
-
var
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
|
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
|
|
910
|
-
|
|
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
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
|
956
|
-
|
|
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: "
|
|
1029
|
+
found.push({ start, end, kind: "private key", mask: KEY_MASK });
|
|
959
1030
|
}
|
|
960
|
-
for (const
|
|
961
|
-
for (const token of line.matchAll(pattern))
|
|
962
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1011
|
-
|
|
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,
|
|
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
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
|
1054
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
this.
|
|
1078
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
|
1126
|
-
findings.push({ file, line: number, 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 (
|
|
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.
|
|
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
|
-
|
|
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
|
}
|