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