@kaydash9999/banker-plugins 0.26.0 → 0.26.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.
@@ -0,0 +1,122 @@
1
+ // make-evidence: what the user must decide before anything runs. Staged changes, changes outside the evidence
2
+ // paths, and hosts that are not this machine in config files and in this process's environment. Only names,
3
+ // places and hosts are shown, never a value. Pure Node, no network.
4
+ import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { join, relative } from "node:path";
6
+ import { EVIDENCE, EXCLUDE, MAX_TEXT, git, gitOut, headOf, lines, otherStatus, topOf } from "./me-git.mjs";
7
+
8
+
9
+ const CONFIG_NAME = /^(\.env(\..+)?|.+\.env|(docker-)?compose.*\.ya?ml|application.*\.(ya?ml|properties)|appsettings.*\.json|database\.ya?ml|settings.*\.(py|json|ya?ml|toml)|config.*\.(json|ya?ml|toml|ini|js|mjs|cjs|ts)|.*\.properties|knexfile\..+|ormconfig\..+|schema\.prisma|alembic\.ini)$/i;
10
+ const SKIP_DIR = new Set([".git", "node_modules", ".venv", "venv", "vendor", "dist", "build", "target", ".next", "__pycache__", ".tox", ".mypy_cache"]);
11
+ const URL_HOST = /\b[a-z][\w+.-]*:\/\/(?:[^\s@/]+@)?(\[[^\]]+\]|[^\s/:'"?#,;)]+)/gi;
12
+ const KEY_HOST = /\b[\w.-]*(?:host|hostname|server|addr|address|endpoint|data source)\b["']?\s*[=:]\s*["']?([^\s"',;)]+)/gi;
13
+ const ENV_KEY = /(?:URL|URI|DSN|HOST|HOSTS|ADDR|ADDRESS|ENDPOINT|SERVER|SERVERS|BROKERS|NODES)$/i;
14
+ // Values that name a host without a URL: connection strings (Server=..., Host=..., Data Source=...).
15
+ const VALUE_HOST = /(?:^|;)\s*(?:server|host|data source|address|addr)\s*=/i;
16
+ // A secret's value is never read for a host: it could be printed.
17
+ const ENV_SECRET = /PASS|PWD|SECRET|TOKEN|CREDENTIAL|PRIVATE|_KEY$/i;
18
+ const ENV_SKIP = /^(HOSTNAME|HOST|HOSTTYPE|DISPLAY|.*_?PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|BANKER_.*|DBUS_.*|XDG_.*)$/i;
19
+ // Names that database and messaging libraries read from the environment without the project naming them.
20
+ // Spring Boot and Quarkus take any setting from the environment, ASP.NET Core takes ConnectionStrings__*, Azure App
21
+ // Service sets *CONNSTR_*.
22
+ const ENV_DATA = /^(PG[A-Z]*|MYSQL_.*|POSTGRES.*|SPRING_.*|QUARKUS_.*|ConnectionStrings__.*|(?:SQL|SQLAZURE|MYSQL|POSTGRESQL|CUSTOM)CONNSTR_.*|DATABASE_.*|DB_.*|.*_DATABASE_URL|MONGO.*|REDIS.*|AMQP_.*|RABBITMQ_.*|KAFKA_.*|CELERY_.*|ELASTIC.*|OPENSEARCH_.*|SMTP_.*|MAIL_.*|AWS_ENDPOINT.*|S3_.*)$/i;
23
+ const PLACEHOLDER = /^(\$|\{|<|%|\*|example\.|.*\.example$|.*\.invalid$|your[-_])/i;
24
+ const MAX_DEPTH = 4;
25
+
26
+ // Files that commonly hold where a service connects, up to four folders down; a linked file is read through.
27
+ function configFiles(top) {
28
+ const out = [];
29
+ const descend = (p, name, depth) => depth < MAX_DEPTH && !SKIP_DIR.has(name) && relative(top, p) !== EVIDENCE;
30
+ const keep = (p) => { try { const st = statSync(p); return st.isFile() && st.size <= MAX_TEXT; } catch { return false; } }; // a broken link
31
+ const walk = (dir, depth) => {
32
+ let entries;
33
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
34
+ for (const e of entries) {
35
+ const p = join(dir, e.name);
36
+ if (e.isDirectory()) { if (descend(p, e.name, depth)) walk(p, depth + 1); }
37
+ else if (CONFIG_NAME.test(e.name) && keep(p)) out.push(p);
38
+ }
39
+ };
40
+ walk(top, 0);
41
+ return out;
42
+ }
43
+
44
+ // Service names in compose files: those name containers on this machine.
45
+ function composeServices(files) {
46
+ const names = new Set();
47
+ for (const f of files.filter((p) => /compose.*\.ya?ml$/i.test(p))) {
48
+ let inServices = false;
49
+ for (const line of readFileSync(f, "utf8").split(/\r?\n/)) {
50
+ if (/^services:\s*$/.test(line)) { inServices = true; continue; }
51
+ if (/^\S/.test(line)) inServices = false;
52
+ const m = inServices && line.match(/^ {2}([\w.-]+):\s*$/);
53
+ if (m) names.add(m[1]);
54
+ }
55
+ }
56
+ return names;
57
+ }
58
+
59
+ export function isLocalHost(h, services = new Set()) {
60
+ const host = h.toLowerCase().replace(/^\[|\]$/g, "");
61
+ if (["localhost", "::1", "0.0.0.0", "host.docker.internal"].includes(host) || host.startsWith("127.")) return true;
62
+ return services.has(host);
63
+ }
64
+
65
+ function hostsIn(text, services) {
66
+ const found = [];
67
+ for (const re of [URL_HOST, KEY_HOST]) {
68
+ for (const m of text.matchAll(re)) {
69
+ const host = m[1].replace(/^\[|\]$/g, "").replace(/:\d+$/, "");
70
+ if (host && !PLACEHOLDER.test(host) && !/^\d+$|[/=]/.test(host) && !isLocalHost(host, services)) found.push(host);
71
+ }
72
+ }
73
+ return found;
74
+ }
75
+
76
+ // Environment variables that may say where the service connects. Asked about: those the project's tracked files
77
+ // name (one git grep for all), and those database and messaging libraries read on their own (PGHOST,
78
+ // SPRING_DATASOURCE_URL, REDIS_URL...). The rest are only noted: the shell's settings for other tools.
79
+ function envCandidates(top, env) {
80
+ const candidate = (k) => ENV_KEY.test(k) || ENV_DATA.test(k) || /:\/\//.test(env[k]) || VALUE_HOST.test(env[k]);
81
+ const keys = Object.keys(env).filter((k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) && env[k] && !ENV_SKIP.test(k) && !ENV_SECRET.test(k) && candidate(k));
82
+ if (!keys.length) return [];
83
+ // the evidence paths are left out: an earlier precheck log names these variables itself
84
+ const named = new Set(lines(git(top, ["grep", "-I", "-o", "-h", "-w", "-F", ...keys.flatMap((k) => ["-e", k]), "--", ".", ...EXCLUDE]).stdout));
85
+ return keys.map((k) => ({ k, ask: named.has(k) || ENV_DATA.test(k) }));
86
+ }
87
+
88
+ export function foreignHosts(top, env = process.env) {
89
+ const files = configFiles(top);
90
+ const services = composeServices(files);
91
+ const found = [];
92
+ for (const f of files) {
93
+ readFileSync(f, "utf8").split(/\r?\n/).forEach((line, i) => {
94
+ if (/^\s*(#|\/\/)/.test(line)) return;
95
+ for (const host of hostsIn(line, services)) found.push({ where: `${relative(top, f).split("\\").join("/")}:${i + 1}`, host });
96
+ });
97
+ }
98
+ for (const { k, ask } of envCandidates(top, env)) {
99
+ const v = env[k];
100
+ const text = /:\/\//.test(v) || VALUE_HOST.test(v) ? v : /HOST|SERVER|ADDR|BROKER|NODE/i.test(k) ? v.split(",").map((h) => `host=${h}`).join(" ") : `${k}=${v}`;
101
+ for (const host of hostsIn(text, services)) found.push({ where: `env ${k}`, host, note: !ask });
102
+ }
103
+ return found;
104
+ }
105
+
106
+ export function precheck(repo, env = process.env) {
107
+ const top = topOf(repo);
108
+ const staged = lines(gitOut(top, ["diff", "--cached", "--name-only"]));
109
+ const dirty = lines(otherStatus(top));
110
+ const ignored = ["logs/x.log", "logs/x.patch"].filter((p) => git(top, ["check-ignore", "-q", `${EVIDENCE}/${p}`]).status === 0);
111
+ const all = foreignHosts(top, env);
112
+ const hosts = all.filter((h) => !h.note);
113
+ const out = [`head ${headOf(top)}`];
114
+ for (const f of staged) out.push(`STOP staged ${f}`);
115
+ for (const l of dirty) out.push(`ASK dirty ${l}`);
116
+ // an environment variable's host is not printed: this output goes into a log that is committed
117
+ for (const h of hosts) out.push(h.where.startsWith("env ") ? `ASK host ${h.where} (이 컴퓨터 밖 주소)` : `ASK host ${h.where} ${h.host}`);
118
+ for (const k of new Set(all.filter((x) => x.note).map((x) => x.where))) out.push(`NOTE host ${k} (이 컴퓨터 밖 주소, 저장소가 부르지 않는 환경 변수. 서비스가 읽는다면 묻기)`);
119
+ for (const p of ignored) out.push(`NOTE ignored ${EVIDENCE}/${p} (commit 이 logs 아래의 .log 와 .patch 만 강제로 넣음)`);
120
+ out.push("NOTE precheck 는 흔한 설정 꼴만 봄. 시작 스크립트와 설정 로더가 읽는 곳도 직접 확인");
121
+ return { out, decide: staged.length + dirty.length + hosts.length > 0 };
122
+ }
@@ -0,0 +1,214 @@
1
+ // make-evidence: runs a command into a log (secrets masked, exit code kept, time limit over the process tree),
2
+ // and starts or stops a server. Pure Node, no network beyond the ready check on the URL it is given.
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { EXCLUDE, Usage, git, lines, runDir, stamp } from "./me-git.mjs";
7
+
8
+ // ---- secrets --------------------------------------------------------------------------------------------------
9
+
10
+ const KEYWORD = "(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|credential)";
11
+ const SECRETS = [
12
+ [/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1***"],
13
+ [new RegExp(`(\\b[A-Za-z0-9_]*${KEYWORD}[A-Za-z0-9_]*["']?\\s*[:=]\\s*["']?)[^\\s"'&,;]+`, "gi"), "$1***"],
14
+ [new RegExp(`(--?${KEYWORD}[A-Za-z0-9_-]*(?:=|\\s+))[^\\s"'-][^\\s"']*`, "gi"), "$1***"],
15
+ [/(\bmysql(?:dump)?\b[^\n]*?\s-p)[^\s"']+/gi, "$1***"],
16
+ [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, "$1***@"],
17
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "***"],
18
+ [/\b(?:sk-[A-Za-z0-9_-]{16,}|sk_(?:live|test)_[A-Za-z0-9]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})/g, "***"],
19
+ [/(\b(?:cookie|set-cookie):\s*)\S[^\r\n]*/gi, "$1***"],
20
+ ];
21
+
22
+ export function redact(text) {
23
+ let n = 0;
24
+ for (const [re, to] of SECRETS) {
25
+ for (const m of text.matchAll(re)) if (!m[0].endsWith("***") && !m[0].endsWith("***@")) n++;
26
+ text = text.replace(re, (...m) => (m[0].endsWith("***") || m[0].endsWith("***@") ? m[0] : m[0].replace(new RegExp(re.source, re.flags.replace("g", "")), to)));
27
+ }
28
+ return { text, n };
29
+ }
30
+
31
+ // Places in a text that still look like a secret (already masked ones do not count): line numbers only.
32
+ export function secretLines(text) {
33
+ const hits = new Set();
34
+ const all = text.split("\n");
35
+ all.forEach((line, i) => { if (redact(line).n) hits.add(i + 1); });
36
+ if (redact(text).n && !hits.size) hits.add(1); // a block over several lines
37
+ return [...hits];
38
+ }
39
+
40
+ // ---- logs -----------------------------------------------------------------------------------------------------
41
+
42
+ const quote = (a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`);
43
+
44
+ // The log file to write: {ts} (or @TS@, which PowerShell leaves alone unquoted) filled in, the folder made, an
45
+ // existing file refused.
46
+ export function claimLog(name) {
47
+ const file = resolve(name.replace(/\{ts\}|@TS@/g, stamp()));
48
+ mkdirSync(dirname(file), { recursive: true });
49
+ if (existsSync(file)) throw new Error(`로그가 이미 있음(덮어쓰지 않음): ${file}`);
50
+ return file;
51
+ }
52
+
53
+ function headInfo(cwd) {
54
+ const r = git(cwd, ["rev-parse", "HEAD"]);
55
+ if (r.status !== 0) return { sha: "none", dirty: "?" };
56
+ const s = git(cwd, ["--no-optional-locks", "status", "--porcelain", "--", ".", ...EXCLUDE]);
57
+ return { sha: r.stdout.trim(), dirty: s.status === 0 ? lines(s.stdout).length : "?" };
58
+ }
59
+
60
+ export function header(argv, shell, cwd) {
61
+ const shown = redact(shell ? argv.join(" ") : argv.map(quote).join(" ")).text;
62
+ const h = headInfo(cwd);
63
+ return `$ ${shown}\n# head=${h.sha} dirty=${h.dirty} at=${new Date().toISOString()} node=${process.version} os=${process.platform} cwd=${cwd}\n`;
64
+ }
65
+
66
+ function spawnArgs(argv, shell) {
67
+ if (!shell) return [argv[0], argv.slice(1)];
68
+ const line = argv.join(" ");
69
+ return process.platform === "win32" ? ["powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", line]] : ["/bin/sh", ["-c", line]];
70
+ }
71
+
72
+ export function killTree(pid) {
73
+ if (!pid) return;
74
+ if (process.platform === "win32") {
75
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { cwd: runDir(), stdio: "ignore", windowsHide: true });
76
+ return;
77
+ }
78
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } }
79
+ }
80
+
81
+ // Writes whole lines, masked, in the order they arrive; a partial line waits for its end.
82
+ function lineSink(fd, count) {
83
+ let part = "";
84
+ const put = (s) => { const r = redact(s); count.n += r.n; writeSync(fd, r.text); };
85
+ return {
86
+ push(chunk) {
87
+ part += chunk;
88
+ const i = part.lastIndexOf("\n");
89
+ if (i >= 0) { put(part.slice(0, i + 1)); part = part.slice(i + 1); }
90
+ },
91
+ end() { if (part) put(`${part}\n`); part = ""; },
92
+ };
93
+ }
94
+
95
+ const GRACE_MS = 3000;
96
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
97
+
98
+ function exitText(state, code, signal) {
99
+ if (state.timedOut) return `timeout(${state.timeoutS}s)`;
100
+ if (state.error) return `error(${state.error})`;
101
+ return code === null || code === undefined ? `signal(${signal})` : String(code);
102
+ }
103
+
104
+ // Closes the log once: on close, a little after exit when a descendant still holds the pipes, or on a signal
105
+ // to this helper (the child's tree is ended first).
106
+ function closer(state, fd, sinks, child, done) {
107
+ return (code, signal, how) => {
108
+ if (state.ended) return;
109
+ state.ended = true;
110
+ clearTimeout(state.timer); clearTimeout(state.grace);
111
+ for (const s of SIGNALS) process.removeListener(s, state.onSignal);
112
+ sinks.forEach((s) => s.end());
113
+ if (how === "orphan") { killTree(child.pid); child.stdout.destroy(); child.stderr.destroy(); }
114
+ const exit = how === "interrupted" ? `interrupted(${signal})` : exitText(state, code, signal);
115
+ writeSync(fd, `${how === "orphan" ? "# orphan-output=yes\n" : ""}# redacted=${state.count.n}\nexit=${exit}\n`);
116
+ closeSync(fd);
117
+ appendFileSync(state.file, runMetrics(readFileSync(state.file, "utf8"), exit).map((m) => `METRIC ${m}\n`).join(""));
118
+ done({ file: state.file, code: state.timedOut ? 124 : typeof code === "number" ? code : 1, exit });
119
+ };
120
+ }
121
+
122
+ export async function runLogged({ log, argv, cwd = process.cwd(), timeoutS = 300, shell = false }) {
123
+ if (!argv.length) throw new Usage("실행할 명령이 없음(-- 뒤에 씀)");
124
+ const file = claimLog(log);
125
+ const fd = openSync(file, "wx");
126
+ writeSync(fd, header(argv, shell, cwd));
127
+ const [cmd, args] = spawnArgs(argv, shell);
128
+ return new Promise((done) => {
129
+ const state = { file, timeoutS, count: { n: 0 }, ended: false, timedOut: false, error: null, timer: null, grace: null, onSignal: null };
130
+ const sinks = [lineSink(fd, state.count), lineSink(fd, state.count)];
131
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true });
132
+ const finish = closer(state, fd, sinks, child, done);
133
+ state.onSignal = (sig) => { killTree(child.pid); finish(null, sig, "interrupted"); process.exit(130); };
134
+ for (const s of SIGNALS) process.once(s, state.onSignal);
135
+ state.timer = setTimeout(() => {
136
+ state.timedOut = true;
137
+ killTree(child.pid);
138
+ state.grace = setTimeout(() => finish(null, "SIGKILL", "orphan"), GRACE_MS);
139
+ }, timeoutS * 1000);
140
+ child.stdout.setEncoding("utf8").on("data", (c) => sinks[0].push(c));
141
+ child.stderr.setEncoding("utf8").on("data", (c) => sinks[1].push(c));
142
+ child.on("error", (e) => { state.error = e.code || e.message; finish(null, null); });
143
+ child.on("exit", (code, signal) => { state.grace = setTimeout(() => finish(code, signal, "orphan"), GRACE_MS); });
144
+ child.on("close", (code, signal) => finish(code, signal));
145
+ });
146
+ }
147
+
148
+ // Test counts the common runners print, so a document can cite them: node --test (spec and TAP), pytest, jest.
149
+ const SUMMARIES = [
150
+ [/^\S*\s*(?:ℹ|#) (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$/gm, (m) => [[m[1], m[2]]]],
151
+ [/^=+ (.*\b(?:passed|failed)\b.*) in [\d.]+s/gm, (m) => [...m[1].matchAll(/(\d+) (passed|failed|skipped|errors?|xfailed|xpassed)/g)].map((x) => [x[2], x[1]])],
152
+ [/^Tests:\s+(.*\btotal)\s*$/gm, (m) => [...m[1].matchAll(/(\d+) (passed|failed|skipped|todo|total)/g)].map((x) => [x[2], x[1]])],
153
+ ];
154
+
155
+ export function runMetrics(text, exit) {
156
+ const out = new Map([["run.exit", exit]]);
157
+ for (const [re, pick] of SUMMARIES) for (const m of text.matchAll(re)) for (const [k, v] of pick(m)) out.set(`run.tests.${k}`, v);
158
+ return [...out].map(([k, v]) => `${k}=${v}`);
159
+ }
160
+
161
+ // ---- long-running servers -------------------------------------------------------------------------------------
162
+
163
+ async function reachable(url) {
164
+ try {
165
+ await fetch(url, { signal: AbortSignal.timeout(2000) });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ async function waitReady(child, ready, waitS) {
173
+ const end = Date.now() + waitS * 1000;
174
+ while (Date.now() < end) {
175
+ if (child.exitCode !== null || child.signalCode !== null) return `not-ready(exited ${child.exitCode ?? child.signalCode})`;
176
+ if (await reachable(ready)) return null;
177
+ await new Promise((r) => setTimeout(r, 500));
178
+ }
179
+ return `not-ready(${waitS}s)`;
180
+ }
181
+
182
+ export async function serve({ log, pidFile, ready, waitS = 60, argv, cwd = process.cwd(), shell = false }) {
183
+ if (!argv.length) throw new Usage("실행할 명령이 없음(-- 뒤에 씀)");
184
+ if (existsSync(pidFile)) throw new Error(`pid 파일이 이미 있음: ${pidFile} (먼저 stop)`);
185
+ if (await reachable(ready)) throw new Error(`이미 답하는 서버가 있음: ${ready} (띄우지 않음. 이미 떠 있던 서버로 다룸)`);
186
+ const file = claimLog(log);
187
+ const fd = openSync(file, "wx");
188
+ writeSync(fd, header(argv, shell, cwd));
189
+ const [cmd, args] = spawnArgs(argv, shell);
190
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", fd, fd], detached: true, windowsHide: true });
191
+ closeSync(fd);
192
+ writeFileSync(pidFile, `${child.pid}\n${file}\n`);
193
+ const why = await waitReady(child, ready, waitS);
194
+ child.unref();
195
+ if (!why) return { file, pid: child.pid, ready: true };
196
+ stop({ pidFile, why });
197
+ return { file, pid: child.pid, ready: false, why };
198
+ }
199
+
200
+ // Ends the server's process tree, masks the secrets its log may hold and closes the log with its exit line.
201
+ export function stop({ pidFile, why: given = "stopped" }) {
202
+ let why = given;
203
+ const [pid, file] = lines(readFileSync(pidFile, "utf8"));
204
+ let alive = true;
205
+ try { process.kill(Number(pid), 0); } catch (e) { alive = e.code === "EPERM"; }
206
+ if (!alive && why === "stopped") why = "gone-before-stop";
207
+ killTree(Number(pid));
208
+ if (file && existsSync(file)) {
209
+ const r = redact(readFileSync(file, "utf8"));
210
+ writeFileSync(file, `${r.text}${r.text.endsWith("\n") ? "" : "\n"}# redacted=${r.n}\nexit=${why}\n`);
211
+ }
212
+ rmSync(pidFile, { force: true });
213
+ return { pid: Number(pid), file };
214
+ }