@optima-chat/dev-skills 0.16.9 → 0.16.10
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/bin/helpers/billing-http.ts +10 -13
- package/bin/helpers/db-utils.ts +16 -10
- package/bin/helpers/infisical-secrets.ts +8 -7
- package/bin/helpers/query-db.ts +11 -4
- package/bin/helpers/safe-exec.ts +252 -0
- package/bin/helpers/show-env.ts +6 -5
- package/dist/bin/helpers/billing-http.js +7 -6
- package/dist/bin/helpers/db-utils.js +15 -6
- package/dist/bin/helpers/infisical-secrets.js +8 -4
- package/dist/bin/helpers/query-db.js +9 -1
- package/dist/bin/helpers/safe-exec.js +321 -0
- package/dist/bin/helpers/show-env.js +6 -2
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runCurl, scrub } from './safe-exec';
|
|
2
2
|
import { fetchInfisicalSecret } from './infisical-secrets';
|
|
3
3
|
import { getInfisicalConfig, getInfisicalToken, getCnInfisicalToken, getCnSecrets, resolveUserId } from './db-utils';
|
|
4
4
|
|
|
@@ -152,25 +152,22 @@ export function getServiceToken(env: string, scope?: string): string {
|
|
|
152
152
|
// execFileSync + 参数数组(不经 shell):Windows cmd.exe 不认单引号,shell 拼出的
|
|
153
153
|
// `-d '${body}'` 会被拆碎、curl 收到垃圾参数直接退出(#92)。数组传参绕开 shell、
|
|
154
154
|
// 跨平台一致;函数保持同步。
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
],
|
|
163
|
-
{ encoding: 'utf-8' },
|
|
164
|
-
);
|
|
155
|
+
// runCurl:失败时的报错不回显命令参数(请求体含凭据)。
|
|
156
|
+
const response = runCurl([
|
|
157
|
+
'-X', 'POST',
|
|
158
|
+
`${authUrl}/api/v1/oauth/token`,
|
|
159
|
+
'-H', 'Content-Type: application/x-www-form-urlencoded',
|
|
160
|
+
'-d', body,
|
|
161
|
+
]);
|
|
165
162
|
|
|
166
163
|
let parsed: { access_token?: string; error?: string };
|
|
167
164
|
try {
|
|
168
165
|
parsed = JSON.parse(response);
|
|
169
166
|
} catch {
|
|
170
|
-
throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${response.slice(0, 200)}`);
|
|
167
|
+
throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${scrub(response).slice(0, 200)}`);
|
|
171
168
|
}
|
|
172
169
|
if (!parsed.access_token) {
|
|
173
|
-
throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
|
|
170
|
+
throw new Error(`user-auth token mint failed (${env}): ${scrub(response).slice(0, 200)}`);
|
|
174
171
|
}
|
|
175
172
|
tokenCache[cacheKey] = parsed.access_token;
|
|
176
173
|
return parsed.access_token;
|
package/bin/helpers/db-utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { execSync,
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
|
+
import { runCurl, scrub } from './safe-exec';
|
|
2
3
|
import * as fs from 'fs';
|
|
3
4
|
import * as os from 'os';
|
|
4
5
|
|
|
@@ -92,10 +93,11 @@ export function getInfisicalToken(config: InfisicalConfig): string {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
|
|
96
|
+
// runCurl:参数数组直传(不经 shell),失败时的报错不回显命令参数。
|
|
97
|
+
const response = runCurl([
|
|
98
|
+
`${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}`,
|
|
99
|
+
'-H', `Authorization: Bearer ${token}`,
|
|
100
|
+
]);
|
|
99
101
|
const data = JSON.parse(response);
|
|
100
102
|
const secrets: Record<string, string> = {};
|
|
101
103
|
for (const secret of data.secrets || []) {
|
|
@@ -105,11 +107,15 @@ export function getInfisicalSecrets(config: InfisicalConfig, token: string, envi
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
// ─── cn Infisical(独立实例,admin email/password 认证)──────────────────────
|
|
108
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* curl → JSON。参数数组直传(避免 shell 引号坑,跨平台安全)。
|
|
112
|
+
* 走 runCurl:curl 失败时抛的错误只带退出码 / 主机名 / 清洗过的 stderr,不回显命令参数
|
|
113
|
+
* (参数里有请求体与认证头)。
|
|
114
|
+
*/
|
|
109
115
|
function curlJson(args: string[]): any {
|
|
110
|
-
const out =
|
|
116
|
+
const out = runCurl(args);
|
|
111
117
|
try { return JSON.parse(out || '{}'); }
|
|
112
|
-
catch { throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`); }
|
|
118
|
+
catch { throw new Error(`cn Infisical: non-JSON response: ${scrub(String(out)).slice(0, 200)}`); }
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
/** 明文密码文件权限比 600 宽(组/其他用户可读)时往 stderr 警告一行。Windows 无此语义,跳过。 */
|
|
@@ -187,14 +193,14 @@ export function getCnInfisicalToken(): string {
|
|
|
187
193
|
'-H', 'Content-Type: application/json',
|
|
188
194
|
'-d', JSON.stringify({ email, password }),
|
|
189
195
|
]);
|
|
190
|
-
if (!login.accessToken) throw new Error(`cn Infisical login 失败: ${JSON.stringify(login).slice(0, 200)}`);
|
|
196
|
+
if (!login.accessToken) throw new Error(`cn Infisical login 失败: ${scrub(JSON.stringify(login)).slice(0, 200)}`);
|
|
191
197
|
const org = curlJson([
|
|
192
198
|
'-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/select-organization`,
|
|
193
199
|
'-H', 'Content-Type: application/json',
|
|
194
200
|
'-H', `Authorization: Bearer ${login.accessToken}`,
|
|
195
201
|
'-d', JSON.stringify({ organizationId: CN_INFISICAL_ORG }),
|
|
196
202
|
]);
|
|
197
|
-
if (!org.token) throw new Error(`cn Infisical select-organization 失败: ${JSON.stringify(org).slice(0, 200)}`);
|
|
203
|
+
if (!org.token) throw new Error(`cn Infisical select-organization 失败: ${scrub(JSON.stringify(org)).slice(0, 200)}`);
|
|
198
204
|
return org.token;
|
|
199
205
|
}
|
|
200
206
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runCurl, scrub } from './safe-exec';
|
|
2
2
|
import { getInfisicalConfig, getInfisicalToken, InfisicalConfig } from './db-utils';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -23,19 +23,20 @@ export function fetchInfisicalSecret(
|
|
|
23
23
|
const encodedPath = encodeURIComponent(secretPath);
|
|
24
24
|
const encodedName = encodeURIComponent(secretName);
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
|
|
26
|
+
// runCurl:参数数组直传(不经 shell),失败时的报错不回显命令参数。
|
|
27
|
+
const response = runCurl([
|
|
28
|
+
`${cfg.url}/api/v3/secrets/raw/${encodedName}?workspaceId=${cfg.projectId}&environment=${envSlug}&secretPath=${encodedPath}`,
|
|
29
|
+
'-H', `Authorization: Bearer ${tok}`,
|
|
30
|
+
]);
|
|
30
31
|
|
|
31
32
|
let parsed: { secret?: { secretValue?: string }; message?: string };
|
|
32
33
|
try {
|
|
33
34
|
parsed = JSON.parse(response);
|
|
34
35
|
} catch {
|
|
35
|
-
throw new Error(`Infisical raw secret fetch returned non-JSON for ${secretPath}/${secretName} (${envSlug}): ${response.slice(0, 200)}`);
|
|
36
|
+
throw new Error(`Infisical raw secret fetch returned non-JSON for ${secretPath}/${secretName} (${envSlug}): ${scrub(response).slice(0, 200)}`);
|
|
36
37
|
}
|
|
37
38
|
if (!parsed.secret?.secretValue) {
|
|
38
|
-
throw new Error(`Infisical secret not found: env=${envSlug} path=${secretPath} name=${secretName} (response: ${response.slice(0, 200)})`);
|
|
39
|
+
throw new Error(`Infisical secret not found: env=${envSlug} path=${secretPath} name=${secretName} (response: ${scrub(response).slice(0, 200)})`);
|
|
39
40
|
}
|
|
40
41
|
return parsed.secret.secretValue;
|
|
41
42
|
}
|
package/bin/helpers/query-db.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
|
+
import { sanitizeExecError } from './safe-exec';
|
|
4
5
|
import * as fs from 'fs';
|
|
5
6
|
import { ensureTunnel, getGitHubVariable, getInfisicalConfig, getInfisicalToken, getInfisicalSecrets, parseDatabaseUrl, isCnEnv, connectCnDB, connectCnDBFromUrl } from './db-utils';
|
|
6
7
|
|
|
@@ -305,10 +306,16 @@ async function main() {
|
|
|
305
306
|
|
|
306
307
|
const { container, user, database } = serviceConfig as any;
|
|
307
308
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
309
|
+
// 密码经 SSHPASS 环境变量给 `sshpass -e`:不进命令行(ps 不可见),失败时的报错也不会带出它。
|
|
310
|
+
let result: string;
|
|
311
|
+
try {
|
|
312
|
+
result = execSync(
|
|
313
|
+
`sshpass -e ssh -o StrictHostKeyChecking=no ${ciUser}@${ciHost} "docker exec ${container} psql -U ${user} -d ${database} -c \\"${sql}\\""`,
|
|
314
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, SSHPASS: ciPassword } }
|
|
315
|
+
);
|
|
316
|
+
} catch (raw) {
|
|
317
|
+
throw sanitizeExecError('ssh (CI database)', raw, [ciPassword], `host=${ciHost}`);
|
|
318
|
+
}
|
|
312
319
|
|
|
313
320
|
console.log('\n' + result);
|
|
314
321
|
} else {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 子进程调用的「报错不回显命令参数」封装。
|
|
5
|
+
*
|
|
6
|
+
* 背景:Node 的 execSync/execFileSync 失败时,错误对象的 message 是 `Command failed: <完整命令行>`,
|
|
7
|
+
* 且带 `cmd` / `spawnargs` / `stderr` 等字段。命令行里若有请求体、认证头、`-u user:pass`、URL 里的
|
|
8
|
+
* 凭据参数,调用方一句 `console.error(err.message)` 或把报错原文落盘,就把它们带了出去。
|
|
9
|
+
*
|
|
10
|
+
* 做法:失败时**不复用**原始 error——抛一个全新的 Error,只含排错需要的东西(退出码/信号/errno、
|
|
11
|
+
* 目标主机名、子进程 stderr),且 stderr 先过 `scrub`(按字面值 + 常见凭据形态双重清洗)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MASK = '[REDACTED]';
|
|
15
|
+
|
|
16
|
+
/** 值整体敏感的 curl 选项(下一个 argv 元素或 `--opt=value` 的 value 整体打码)。 */
|
|
17
|
+
const VALUE_IS_SECRET = new Set([
|
|
18
|
+
'-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--data-ascii', '--json',
|
|
19
|
+
'-u', '--user', '-U', '--proxy-user', '--oauth2-bearer',
|
|
20
|
+
'-x', '--proxy', '--preproxy', // 代理 URL 常带 user:pass@,且可能无 scheme
|
|
21
|
+
'-F', '--form', '--form-string', '-b', '--cookie', '--pass', '--key',
|
|
22
|
+
'--tlspassword', '--proxy-tlspassword', '--proxy-pass', '--proxy-key',
|
|
23
|
+
'-E', '--cert', '--proxy-cert', // `<file>:<passphrase>`
|
|
24
|
+
'--variable', '--aws-sigv4',
|
|
25
|
+
]);
|
|
26
|
+
/** 带值的短选项字母:解析 `-sSu user:pass` / `-XPOST` 这类合写簇时,遇到它簇就结束(其后是值)。 */
|
|
27
|
+
const SHORT_WITH_VALUE = new Set('dubFHxeAXoKEUmTrCQYyzwPtcDh'.split('').filter((c) => c !== 'h'));
|
|
28
|
+
const HEADER_OPTS = new Set(['-H', '--header', '--proxy-header']);
|
|
29
|
+
/** 头值白名单:只有这些无害头保留原值,其余一律整值打码(黑名单永远列不全)。 */
|
|
30
|
+
const SAFE_HEADER_RE = /^(content-type|content-length|content-encoding|accept|accept-encoding|accept-language|user-agent|cache-control|connection|host|origin)$/i;
|
|
31
|
+
/** 参数/字段名像凭据 ⇒ 值打码。`secret` 单独判:`secretPath` / `expandSecretReferences` 这类是路径与开关,不是凭据。 */
|
|
32
|
+
const SECRET_NAME_RE = /(pass(?:word|wd)?|pwd|token|jwt|hmac|assertion|api[-_]?key|access[-_]?key|private[-_]?key|signature|credential|session|(?:^|[-_])(?:sig|sign|auth|code|key|otp|pw|ticket)$)/i;
|
|
33
|
+
const SECRET_WORD_RE = /secret/i;
|
|
34
|
+
/**
|
|
35
|
+
* 「名字像凭据、其实不是」的排除——**必须锚定**:无锚点的子串排除会把 `provider_secret`(prov-id-er)、
|
|
36
|
+
* `model_secret`(mode-l)这类真凭据放过去。只认两种形态:
|
|
37
|
+
* - 整名是已知的路径/开关参数;
|
|
38
|
+
* - 以「凭据词 + 明确的非凭据后缀」结尾(`secret_id` / `token_type` / `max_tokens` …)。
|
|
39
|
+
*/
|
|
40
|
+
const KNOWN_NON_SECRET = new Set(['secretpath', 'expandsecretreferences', 'sshpass', 'tokenizer', 'passthrough', 'bypass']);
|
|
41
|
+
const NON_SECRET_SUFFIX_RE = /(?:secret|token|key)s?[-_]?(?:path|references?|names?|ids?|version|types?|mode|enabled|count|limit|length|ttl|expiry|expires(?:[-_]?(?:at|in))?)$|(?:^|[-_])(?:max|min|num|total|input|output|prompt|completion)[-_]?tokens$/i;
|
|
42
|
+
export function isSecretName(name: string): boolean {
|
|
43
|
+
const n = name.trim();
|
|
44
|
+
if (KNOWN_NON_SECRET.has(n.toLowerCase()) || NON_SECRET_SUFFIX_RE.test(n)) return false;
|
|
45
|
+
return SECRET_NAME_RE.test(n) || SECRET_WORD_RE.test(n);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redactUrl(raw: string): string {
|
|
49
|
+
let u: URL;
|
|
50
|
+
try { u = new URL(raw); } catch { return raw; }
|
|
51
|
+
if (!/^https?:$/.test(u.protocol)) return raw;
|
|
52
|
+
if (u.username || u.password) { u.username = MASK; u.password = ''; }
|
|
53
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
54
|
+
if (isSecretName(key)) u.searchParams.set(key, MASK);
|
|
55
|
+
}
|
|
56
|
+
if (u.hash.length > 1) u.hash = MASK; // fragment 常见 `#access_token=…`,对排错无用,整段打码
|
|
57
|
+
return u.toString().replace(/%5BREDACTED%5D/g, MASK);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function redactHeader(h: string): string {
|
|
61
|
+
const i = h.indexOf(':');
|
|
62
|
+
if (i <= 0) return h;
|
|
63
|
+
const name = h.slice(0, i).trim();
|
|
64
|
+
return SAFE_HEADER_RE.test(name) ? h : `${name}: ${MASK}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 返回脱敏后的 argv 副本(仅用于展示/排错;绝不改传给子进程的真参数)。
|
|
69
|
+
* 不覆盖:藏在 URL **路径段**里的凭据(webhook 型 URL)与 User-Agent 里夹带的值——这类请走请求头/请求体。
|
|
70
|
+
*/
|
|
71
|
+
export function redactArgv(args: readonly string[]): string[] {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
for (let i = 0; i < args.length; i++) {
|
|
74
|
+
const a = args[i];
|
|
75
|
+
// --opt=value
|
|
76
|
+
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
77
|
+
if (eq > 0) {
|
|
78
|
+
const opt = a.slice(0, eq);
|
|
79
|
+
if (opt === '--url') { out.push(`${opt}=${redactUrl(a.slice(eq + 1))}`); continue; }
|
|
80
|
+
if (VALUE_IS_SECRET.has(opt)) { out.push(`${opt}=${MASK}`); continue; }
|
|
81
|
+
if (HEADER_OPTS.has(opt)) { out.push(`${opt}=${redactHeader(a.slice(eq + 1))}`); continue; }
|
|
82
|
+
}
|
|
83
|
+
// 选项与值分开
|
|
84
|
+
if (VALUE_IS_SECRET.has(a)) { out.push(a); if (i + 1 < args.length) { out.push(MASK); i++; } continue; }
|
|
85
|
+
if (HEADER_OPTS.has(a)) { out.push(a); if (i + 1 < args.length) { out.push(redactHeader(args[i + 1])); i++; } continue; }
|
|
86
|
+
// 短选项簇:-Hfoo / -dbody / -uuser:pass / -sSu user:pass / -XPOST
|
|
87
|
+
if (/^-[^-]/.test(a) && a.length > 2) {
|
|
88
|
+
let j = 1;
|
|
89
|
+
while (j < a.length && !SHORT_WITH_VALUE.has(a[j])) j++; // 跳过不带值的开关字母
|
|
90
|
+
if (j < a.length) {
|
|
91
|
+
const opt = `-${a[j]}`, head = a.slice(0, j + 1), rest = a.slice(j + 1);
|
|
92
|
+
const sticky = rest.length > 0; // 值粘在簇里,否则是下一个 argv
|
|
93
|
+
const value = sticky ? rest : args[i + 1];
|
|
94
|
+
if (VALUE_IS_SECRET.has(opt) || HEADER_OPTS.has(opt)) {
|
|
95
|
+
const masked = value === undefined ? undefined : (HEADER_OPTS.has(opt) ? redactHeader(value) : MASK);
|
|
96
|
+
if (sticky) out.push(`${head}${masked}`); else { out.push(head); if (masked !== undefined) { out.push(masked); i++; } }
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (/^https?:\/\//i.test(a)) { out.push(redactUrl(a)); continue; }
|
|
102
|
+
// 无 scheme 的 `user:pass@host…`(curl 接受)
|
|
103
|
+
out.push(a.replace(/^[^\s/@:]+:[^\s/@]+@/, `${MASK}@`));
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 从 argv 里收集「字面值就是秘密」的串,供 scrub 在任意文本里逐字清除。 */
|
|
109
|
+
function secretLiterals(args: readonly string[]): string[] {
|
|
110
|
+
const red = redactArgv(args);
|
|
111
|
+
const lits: string[] = [];
|
|
112
|
+
for (let i = 0; i < args.length; i++) {
|
|
113
|
+
if (!red[i].includes(MASK)) continue; // 按「是否真的打了码」判,不按字符串是否相等(URL 规范化也会让它不等)
|
|
114
|
+
const a = args[i].replace(/^--url=/, '');
|
|
115
|
+
// 子进程 stderr 可能只回显参数的一段,所以除整串外还要收内部片段——但**只收名字像凭据的值**:
|
|
116
|
+
// 把 URL 里所有 k=v 都当秘密,会让 `prod` / `true` 这类普通词把报错原文洗得没法读。
|
|
117
|
+
if (/^https?:\/\//i.test(a)) {
|
|
118
|
+
try {
|
|
119
|
+
const u = new URL(a);
|
|
120
|
+
if (u.password) lits.push(u.password, decodeURIComponent(u.password));
|
|
121
|
+
if (u.username && u.password) lits.push(`${u.username}:${u.password}`);
|
|
122
|
+
for (const [k, v] of u.searchParams) if (isSecretName(k)) lits.push(v);
|
|
123
|
+
if (u.hash.length > 1) { // fragment:`#access_token=…`
|
|
124
|
+
lits.push(u.hash.slice(1));
|
|
125
|
+
for (const [, v] of new URLSearchParams(u.hash.slice(1))) if (v) lits.push(v);
|
|
126
|
+
}
|
|
127
|
+
} catch { /* 非法 URL:redactUrl 也不会动它,走不到这里 */ }
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
lits.push(a); // 非 URL 的敏感参数(请求体 / -u / 认证头 / 代理):整串即秘密
|
|
131
|
+
for (const m of a.matchAll(/"([^"]*)"\s*:\s*"([^"]+)"/g)) if (isSecretName(m[1])) lits.push(m[2]); // JSON 字段
|
|
132
|
+
for (const m of a.matchAll(/(?:^|&)([^=&\s]+)=([^&\s]+)/g)) { // form k=v
|
|
133
|
+
if (isSecretName(m[1])) { lits.push(m[2]); try { lits.push(decodeURIComponent(m[2])); } catch { /* 裸 % */ } }
|
|
134
|
+
}
|
|
135
|
+
const header = /^[A-Za-z0-9-]+:\s*(.+)$/.exec(a);
|
|
136
|
+
if (header) { // Header: value
|
|
137
|
+
lits.push(header[1].trim());
|
|
138
|
+
const scheme = /^(?:Bearer|Basic|Token)\s+(\S+)/i.exec(header[1].trim());
|
|
139
|
+
if (scheme) lits.push(scheme[1]);
|
|
140
|
+
} else {
|
|
141
|
+
const up = /^[^\s/@:]*:([^\s@]+)(?:@|$)/.exec(a); // user:pass[@host]
|
|
142
|
+
if (up) lits.push(up[1]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 长的先替换,避免短片段把长串切碎后漏网;太短的不收(误伤普通文本)
|
|
146
|
+
return [...new Set(lits.filter((s) => s && s.length >= 4))].sort((x, y) => y.length - x.length);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 清洗任意文本:先按字面值,再按常见凭据形态(认证头、Bearer/Basic、URL 凭据、k=v、JSON 字段)。 */
|
|
150
|
+
export function scrub(text: string, literals: readonly string[] = []): string {
|
|
151
|
+
let out = String(text ?? '');
|
|
152
|
+
for (const lit of [...literals].filter((s) => s && s.length >= 4).sort((a, b) => b.length - a.length)) {
|
|
153
|
+
out = out.split(lit).join(MASK);
|
|
154
|
+
const enc = encodeURIComponent(lit);
|
|
155
|
+
if (enc !== lit) out = out.split(enc).join(MASK);
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
.replace(/((?:proxy-)?authorization\s*[:=]\s*)(?:(?:bearer|basic|token)\s+)?[^\s"'\\]+/gi, `$1${MASK}`)
|
|
159
|
+
.replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${MASK}`)
|
|
160
|
+
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:]+:[^\s/]*@/gi, `$1${MASK}@`) // 贪婪到最后一个 @:密码本身可含 @
|
|
161
|
+
// 三条「名字 → 值」规则统一用 isSecretName 判(与 argv 脱敏同一口径)
|
|
162
|
+
// 值类必须排除 `?`:否则非凭据键(`url=http://h/cb?token=X`)会把后面的凭据对整段吞进自己的值里原样返回。
|
|
163
|
+
.replace(/(^|[?&;,(\s"'])(-{0,2}[A-Za-z_][\w.-]*)(\s*=\s*)(?:"[^"]*"|'[^']*'|[^&?;,)\s"']+)/g,
|
|
164
|
+
(m, pre: string, key: string, eq: string) => (isSecretName(key.replace(/^-+/, '')) ? `${pre}${key}${eq}${MASK}` : m))
|
|
165
|
+
.replace(/"([A-Za-z_][\w-]*)"(\s*:\s*)"[^"]*"/g,
|
|
166
|
+
(m, key: string, sep: string) => (isSecretName(key) ? `"${key}"${sep}"${MASK}"` : m))
|
|
167
|
+
.replace(/\b((?:cookie|set-cookie|x-[a-z0-9-]*(?:key|token|secret|auth|session)[a-z0-9-]*|[a-z0-9-]*api-?key)\s*:\s*)[^\r\n"']+/gi, `$1${MASK}`)
|
|
168
|
+
// `name: value`(yaml / 单引号 dict)。值须像一个 token(≥6 且含数字或符号),否则 `password: must be …`
|
|
169
|
+
// 这类普通报错句子会被误洗。
|
|
170
|
+
.replace(/(^|[\s{,])(["']?)([A-Za-z_][\w-]*)\2(\s*:\s*)('[^']*'|[^\s,}"']+)/g,
|
|
171
|
+
(m, pre: string, q: string, key: string, sep: string, val: string) => {
|
|
172
|
+
const bare = val.replace(/^'|'$/g, '');
|
|
173
|
+
const tokenish = bare.length >= 6 && /[\d_\-+/=.]/.test(bare);
|
|
174
|
+
return isSecretName(key) && tokenish ? `${pre}${q}${key}${q}${sep}${MASK}` : m;
|
|
175
|
+
})
|
|
176
|
+
.replace(/(\bsshpass\s+-p\s*)("[^"]*"|'[^']*'|\S+)/g, `$1${MASK}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 值是 URL 但不是请求目标的选项(代理 / referer)。 */
|
|
180
|
+
const URL_VALUED_OPTS = new Set(['-x', '--proxy', '--preproxy', '-e', '--referer']);
|
|
181
|
+
function hostOf(args: readonly string[]): string {
|
|
182
|
+
for (let i = 0; i < args.length; i++) {
|
|
183
|
+
const a = args[i].replace(/^--url=/, '');
|
|
184
|
+
if (!/^https?:\/\//i.test(a) || (i > 0 && URL_VALUED_OPTS.has(args[i - 1]))) continue;
|
|
185
|
+
try { return new URL(a).host; } catch { /* 不是合法 URL:继续找 */ }
|
|
186
|
+
}
|
|
187
|
+
return 'unknown';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 会让 curl 把请求头/响应头/响应体细节写进输出的选项:响应头里的凭据(Set-Cookie 等)没法按字面值清洗,直接不许用。 */
|
|
191
|
+
function findVerboseOpt(args: readonly string[]): string | undefined {
|
|
192
|
+
for (let i = 0; i < args.length; i++) {
|
|
193
|
+
const a = args[i];
|
|
194
|
+
if (a.startsWith('--')) {
|
|
195
|
+
const name = a.split('=')[0];
|
|
196
|
+
// curl 接受长选项的唯一前缀(`--verbos`),所以按前缀判
|
|
197
|
+
if (/^--(verb|trace|incl|dump-h)/.test(name)) return name;
|
|
198
|
+
if (!a.includes('=') && (VALUE_IS_SECRET.has(name) || HEADER_OPTS.has(name) || URL_VALUED_OPTS.has(name))) i++; // 跳过它的值
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!/^-[^-]/.test(a)) continue;
|
|
202
|
+
let j = 1;
|
|
203
|
+
for (; j < a.length; j++) {
|
|
204
|
+
if ('viD'.includes(a[j])) return `-${a[j]}`;
|
|
205
|
+
if (SHORT_WITH_VALUE.has(a[j])) break;
|
|
206
|
+
}
|
|
207
|
+
if (j < a.length && j === a.length - 1) i++; // 簇以带值选项结尾:下一个 argv 是值,跳过
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 把 child_process 抛的 error 换成一个干净的新 Error。
|
|
214
|
+
* 只取 status/signal/code 与(清洗后的)stderr;**不复制** message/cmd/spawnargs/stdout/output。
|
|
215
|
+
*/
|
|
216
|
+
export function sanitizeExecError(label: string, raw: unknown, literals: readonly string[] = [], context = ''): Error {
|
|
217
|
+
const e = (raw ?? {}) as { status?: number | null; signal?: string | null; code?: string | number; stderr?: unknown };
|
|
218
|
+
const parts: string[] = [];
|
|
219
|
+
if (typeof e.status === 'number') parts.push(`exit=${e.status}`);
|
|
220
|
+
if (e.signal) parts.push(`signal=${e.signal}`);
|
|
221
|
+
if (typeof e.code === 'string') parts.push(`code=${e.code}`); // ENOENT / ETIMEDOUT / ENOBUFS…
|
|
222
|
+
if (context) parts.push(context);
|
|
223
|
+
const stderr = scrub(e.stderr == null ? '' : String(e.stderr), literals).trim().slice(0, 2000);
|
|
224
|
+
return new Error(`${label} failed (${parts.join(' ') || 'unknown error'})${stderr ? `: ${stderr}` : ''}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface RunCurlOptions {
|
|
228
|
+
/** 仅测试用:替换可执行文件。 */
|
|
229
|
+
bin?: string;
|
|
230
|
+
/** 毫秒;不传 = 不限(保持原行为)。 */
|
|
231
|
+
timeoutMs?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 同步跑 curl,返回 stdout。参数数组直传(不经 shell)。
|
|
236
|
+
* 失败时抛的 Error 不含 argv:只有退出码/信号/errno、目标主机名、清洗过的 curl stderr。
|
|
237
|
+
* `-sS`:静默进度条但保留 curl 自己的错误行(`curl: (7) Failed to connect…`),否则失败时两眼一抹黑。
|
|
238
|
+
*/
|
|
239
|
+
export function runCurl(args: readonly string[], opts: RunCurlOptions = {}): string {
|
|
240
|
+
const verbose = findVerboseOpt(args);
|
|
241
|
+
if (verbose) throw new Error(`runCurl: option ${verbose} is not allowed (it writes header/body details to the output)`);
|
|
242
|
+
try {
|
|
243
|
+
return execFileSync(opts.bin ?? 'curl', ['-sS', ...args], {
|
|
244
|
+
encoding: 'utf-8',
|
|
245
|
+
stdio: ['ignore', 'pipe', 'pipe'], // stderr 收进 error 对象(由我们清洗后再露出),不直通父进程
|
|
246
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
247
|
+
...(opts.timeoutMs ? { timeout: opts.timeoutMs } : {}),
|
|
248
|
+
});
|
|
249
|
+
} catch (raw) {
|
|
250
|
+
throw sanitizeExecError('curl', raw, secretLiterals(args), `host=${hostOf(args)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
package/bin/helpers/show-env.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { runCurl } from './safe-exec';
|
|
4
4
|
import { getInfisicalConfig, getInfisicalToken, InfisicalConfig, isCnEnv, cnInfisicalEnv, getCnInfisicalToken, getCnSecrets } from './db-utils';
|
|
5
5
|
|
|
6
6
|
// 支持的服务列表(Infisical 路径为 /services/<service-name>)
|
|
@@ -46,10 +46,11 @@ function isSupportedEnv(env: string): boolean {
|
|
|
46
46
|
// NOTE: getInfisicalSecrets is kept local because it encodes secretPath (encodeURIComponent),
|
|
47
47
|
// unlike db-utils' raw-path variant; getGitHubVariable/getInfisicalConfig/getInfisicalToken are shared from db-utils.
|
|
48
48
|
function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
|
|
49
|
+
// runCurl:参数数组直传(不经 shell),失败时的报错不回显命令参数。
|
|
50
|
+
const response = runCurl([
|
|
51
|
+
`${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}`,
|
|
52
|
+
'-H', `Authorization: Bearer ${token}`,
|
|
53
|
+
]);
|
|
53
54
|
const data = JSON.parse(response);
|
|
54
55
|
const secrets: Record<string, string> = {};
|
|
55
56
|
for (const secret of data.secrets || []) {
|
|
@@ -10,7 +10,7 @@ exports.callUserAuthAsAdmin = callUserAuthAsAdmin;
|
|
|
10
10
|
exports.resolveUserIdByEmail = resolveUserIdByEmail;
|
|
11
11
|
exports.resolveUserIdByPhone = resolveUserIdByPhone;
|
|
12
12
|
exports.getUserById = getUserById;
|
|
13
|
-
const
|
|
13
|
+
const safe_exec_1 = require("./safe-exec");
|
|
14
14
|
const infisical_secrets_1 = require("./infisical-secrets");
|
|
15
15
|
const db_utils_1 = require("./db-utils");
|
|
16
16
|
const USER_AUTH_URLS = {
|
|
@@ -159,21 +159,22 @@ function getServiceToken(env, scope) {
|
|
|
159
159
|
// execFileSync + 参数数组(不经 shell):Windows cmd.exe 不认单引号,shell 拼出的
|
|
160
160
|
// `-d '${body}'` 会被拆碎、curl 收到垃圾参数直接退出(#92)。数组传参绕开 shell、
|
|
161
161
|
// 跨平台一致;函数保持同步。
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// runCurl:失败时的报错不回显命令参数(请求体含凭据)。
|
|
163
|
+
const response = (0, safe_exec_1.runCurl)([
|
|
164
|
+
'-X', 'POST',
|
|
164
165
|
`${authUrl}/api/v1/oauth/token`,
|
|
165
166
|
'-H', 'Content-Type: application/x-www-form-urlencoded',
|
|
166
167
|
'-d', body,
|
|
167
|
-
]
|
|
168
|
+
]);
|
|
168
169
|
let parsed;
|
|
169
170
|
try {
|
|
170
171
|
parsed = JSON.parse(response);
|
|
171
172
|
}
|
|
172
173
|
catch {
|
|
173
|
-
throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${response.slice(0, 200)}`);
|
|
174
|
+
throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${(0, safe_exec_1.scrub)(response).slice(0, 200)}`);
|
|
174
175
|
}
|
|
175
176
|
if (!parsed.access_token) {
|
|
176
|
-
throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
|
|
177
|
+
throw new Error(`user-auth token mint failed (${env}): ${(0, safe_exec_1.scrub)(response).slice(0, 200)}`);
|
|
177
178
|
}
|
|
178
179
|
tokenCache[cacheKey] = parsed.access_token;
|
|
179
180
|
return parsed.access_token;
|
|
@@ -57,6 +57,7 @@ exports.connectAuthDB = connectAuthDB;
|
|
|
57
57
|
exports.connectBillingDB = connectBillingDB;
|
|
58
58
|
exports.resolveUserId = resolveUserId;
|
|
59
59
|
const child_process_1 = require("child_process");
|
|
60
|
+
const safe_exec_1 = require("./safe-exec");
|
|
60
61
|
const fs = __importStar(require("fs"));
|
|
61
62
|
const os = __importStar(require("os"));
|
|
62
63
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
@@ -127,7 +128,11 @@ function getInfisicalToken(config) {
|
|
|
127
128
|
]).accessToken;
|
|
128
129
|
}
|
|
129
130
|
function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
130
|
-
|
|
131
|
+
// runCurl:参数数组直传(不经 shell),失败时的报错不回显命令参数。
|
|
132
|
+
const response = (0, safe_exec_1.runCurl)([
|
|
133
|
+
`${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}`,
|
|
134
|
+
'-H', `Authorization: Bearer ${token}`,
|
|
135
|
+
]);
|
|
131
136
|
const data = JSON.parse(response);
|
|
132
137
|
const secrets = {};
|
|
133
138
|
for (const secret of data.secrets || []) {
|
|
@@ -136,14 +141,18 @@ function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
|
136
141
|
return secrets;
|
|
137
142
|
}
|
|
138
143
|
// ─── cn Infisical(独立实例,admin email/password 认证)──────────────────────
|
|
139
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* curl → JSON。参数数组直传(避免 shell 引号坑,跨平台安全)。
|
|
146
|
+
* 走 runCurl:curl 失败时抛的错误只带退出码 / 主机名 / 清洗过的 stderr,不回显命令参数
|
|
147
|
+
* (参数里有请求体与认证头)。
|
|
148
|
+
*/
|
|
140
149
|
function curlJson(args) {
|
|
141
|
-
const out = (0,
|
|
150
|
+
const out = (0, safe_exec_1.runCurl)(args);
|
|
142
151
|
try {
|
|
143
152
|
return JSON.parse(out || '{}');
|
|
144
153
|
}
|
|
145
154
|
catch {
|
|
146
|
-
throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`);
|
|
155
|
+
throw new Error(`cn Infisical: non-JSON response: ${(0, safe_exec_1.scrub)(String(out)).slice(0, 200)}`);
|
|
147
156
|
}
|
|
148
157
|
}
|
|
149
158
|
/** 明文密码文件权限比 600 宽(组/其他用户可读)时往 stderr 警告一行。Windows 无此语义,跳过。 */
|
|
@@ -234,7 +243,7 @@ function getCnInfisicalToken() {
|
|
|
234
243
|
'-d', JSON.stringify({ email, password }),
|
|
235
244
|
]);
|
|
236
245
|
if (!login.accessToken)
|
|
237
|
-
throw new Error(`cn Infisical login 失败: ${JSON.stringify(login).slice(0, 200)}`);
|
|
246
|
+
throw new Error(`cn Infisical login 失败: ${(0, safe_exec_1.scrub)(JSON.stringify(login)).slice(0, 200)}`);
|
|
238
247
|
const org = curlJson([
|
|
239
248
|
'-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/select-organization`,
|
|
240
249
|
'-H', 'Content-Type: application/json',
|
|
@@ -242,7 +251,7 @@ function getCnInfisicalToken() {
|
|
|
242
251
|
'-d', JSON.stringify({ organizationId: CN_INFISICAL_ORG }),
|
|
243
252
|
]);
|
|
244
253
|
if (!org.token)
|
|
245
|
-
throw new Error(`cn Infisical select-organization 失败: ${JSON.stringify(org).slice(0, 200)}`);
|
|
254
|
+
throw new Error(`cn Infisical select-organization 失败: ${(0, safe_exec_1.scrub)(JSON.stringify(org)).slice(0, 200)}`);
|
|
246
255
|
return org.token;
|
|
247
256
|
}
|
|
248
257
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.fetchInfisicalSecret = fetchInfisicalSecret;
|
|
4
|
-
const
|
|
4
|
+
const safe_exec_1 = require("./safe-exec");
|
|
5
5
|
const db_utils_1 = require("./db-utils");
|
|
6
6
|
/**
|
|
7
7
|
* Fetch a single secret value from Infisical given env + path + name.
|
|
@@ -18,16 +18,20 @@ function fetchInfisicalSecret(env, secretPath, secretName, config, token) {
|
|
|
18
18
|
const envSlug = env === 'stage' ? 'staging' : env;
|
|
19
19
|
const encodedPath = encodeURIComponent(secretPath);
|
|
20
20
|
const encodedName = encodeURIComponent(secretName);
|
|
21
|
-
|
|
21
|
+
// runCurl:参数数组直传(不经 shell),失败时的报错不回显命令参数。
|
|
22
|
+
const response = (0, safe_exec_1.runCurl)([
|
|
23
|
+
`${cfg.url}/api/v3/secrets/raw/${encodedName}?workspaceId=${cfg.projectId}&environment=${envSlug}&secretPath=${encodedPath}`,
|
|
24
|
+
'-H', `Authorization: Bearer ${tok}`,
|
|
25
|
+
]);
|
|
22
26
|
let parsed;
|
|
23
27
|
try {
|
|
24
28
|
parsed = JSON.parse(response);
|
|
25
29
|
}
|
|
26
30
|
catch {
|
|
27
|
-
throw new Error(`Infisical raw secret fetch returned non-JSON for ${secretPath}/${secretName} (${envSlug}): ${response.slice(0, 200)}`);
|
|
31
|
+
throw new Error(`Infisical raw secret fetch returned non-JSON for ${secretPath}/${secretName} (${envSlug}): ${(0, safe_exec_1.scrub)(response).slice(0, 200)}`);
|
|
28
32
|
}
|
|
29
33
|
if (!parsed.secret?.secretValue) {
|
|
30
|
-
throw new Error(`Infisical secret not found: env=${envSlug} path=${secretPath} name=${secretName} (response: ${response.slice(0, 200)})`);
|
|
34
|
+
throw new Error(`Infisical secret not found: env=${envSlug} path=${secretPath} name=${secretName} (response: ${(0, safe_exec_1.scrub)(response).slice(0, 200)})`);
|
|
31
35
|
}
|
|
32
36
|
return parsed.secret.secretValue;
|
|
33
37
|
}
|
|
@@ -37,6 +37,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
37
37
|
exports.QueryDbUsageError = void 0;
|
|
38
38
|
exports.parseQueryDbArgs = parseQueryDbArgs;
|
|
39
39
|
const child_process_1 = require("child_process");
|
|
40
|
+
const safe_exec_1 = require("./safe-exec");
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
41
42
|
const db_utils_1 = require("./db-utils");
|
|
42
43
|
const SERVICE_DB_MAP = {
|
|
@@ -299,7 +300,14 @@ async function main() {
|
|
|
299
300
|
const ciHost = (0, db_utils_1.getGitHubVariable)('CI_SSH_HOST');
|
|
300
301
|
const ciPassword = (0, db_utils_1.getGitHubVariable)('CI_SSH_PASSWORD');
|
|
301
302
|
const { container, user, database } = serviceConfig;
|
|
302
|
-
|
|
303
|
+
// 密码经 SSHPASS 环境变量给 `sshpass -e`:不进命令行(ps 不可见),失败时的报错也不会带出它。
|
|
304
|
+
let result;
|
|
305
|
+
try {
|
|
306
|
+
result = (0, child_process_1.execSync)(`sshpass -e ssh -o StrictHostKeyChecking=no ${ciUser}@${ciHost} "docker exec ${container} psql -U ${user} -d ${database} -c \\"${sql}\\""`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, SSHPASS: ciPassword } });
|
|
307
|
+
}
|
|
308
|
+
catch (raw) {
|
|
309
|
+
throw (0, safe_exec_1.sanitizeExecError)('ssh (CI database)', raw, [ciPassword], `host=${ciHost}`);
|
|
310
|
+
}
|
|
303
311
|
console.log('\n' + result);
|
|
304
312
|
}
|
|
305
313
|
else {
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSecretName = isSecretName;
|
|
4
|
+
exports.redactArgv = redactArgv;
|
|
5
|
+
exports.scrub = scrub;
|
|
6
|
+
exports.sanitizeExecError = sanitizeExecError;
|
|
7
|
+
exports.runCurl = runCurl;
|
|
8
|
+
const child_process_1 = require("child_process");
|
|
9
|
+
/**
|
|
10
|
+
* 子进程调用的「报错不回显命令参数」封装。
|
|
11
|
+
*
|
|
12
|
+
* 背景:Node 的 execSync/execFileSync 失败时,错误对象的 message 是 `Command failed: <完整命令行>`,
|
|
13
|
+
* 且带 `cmd` / `spawnargs` / `stderr` 等字段。命令行里若有请求体、认证头、`-u user:pass`、URL 里的
|
|
14
|
+
* 凭据参数,调用方一句 `console.error(err.message)` 或把报错原文落盘,就把它们带了出去。
|
|
15
|
+
*
|
|
16
|
+
* 做法:失败时**不复用**原始 error——抛一个全新的 Error,只含排错需要的东西(退出码/信号/errno、
|
|
17
|
+
* 目标主机名、子进程 stderr),且 stderr 先过 `scrub`(按字面值 + 常见凭据形态双重清洗)。
|
|
18
|
+
*/
|
|
19
|
+
const MASK = '[REDACTED]';
|
|
20
|
+
/** 值整体敏感的 curl 选项(下一个 argv 元素或 `--opt=value` 的 value 整体打码)。 */
|
|
21
|
+
const VALUE_IS_SECRET = new Set([
|
|
22
|
+
'-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--data-ascii', '--json',
|
|
23
|
+
'-u', '--user', '-U', '--proxy-user', '--oauth2-bearer',
|
|
24
|
+
'-x', '--proxy', '--preproxy', // 代理 URL 常带 user:pass@,且可能无 scheme
|
|
25
|
+
'-F', '--form', '--form-string', '-b', '--cookie', '--pass', '--key',
|
|
26
|
+
'--tlspassword', '--proxy-tlspassword', '--proxy-pass', '--proxy-key',
|
|
27
|
+
'-E', '--cert', '--proxy-cert', // `<file>:<passphrase>`
|
|
28
|
+
'--variable', '--aws-sigv4',
|
|
29
|
+
]);
|
|
30
|
+
/** 带值的短选项字母:解析 `-sSu user:pass` / `-XPOST` 这类合写簇时,遇到它簇就结束(其后是值)。 */
|
|
31
|
+
const SHORT_WITH_VALUE = new Set('dubFHxeAXoKEUmTrCQYyzwPtcDh'.split('').filter((c) => c !== 'h'));
|
|
32
|
+
const HEADER_OPTS = new Set(['-H', '--header', '--proxy-header']);
|
|
33
|
+
/** 头值白名单:只有这些无害头保留原值,其余一律整值打码(黑名单永远列不全)。 */
|
|
34
|
+
const SAFE_HEADER_RE = /^(content-type|content-length|content-encoding|accept|accept-encoding|accept-language|user-agent|cache-control|connection|host|origin)$/i;
|
|
35
|
+
/** 参数/字段名像凭据 ⇒ 值打码。`secret` 单独判:`secretPath` / `expandSecretReferences` 这类是路径与开关,不是凭据。 */
|
|
36
|
+
const SECRET_NAME_RE = /(pass(?:word|wd)?|pwd|token|jwt|hmac|assertion|api[-_]?key|access[-_]?key|private[-_]?key|signature|credential|session|(?:^|[-_])(?:sig|sign|auth|code|key|otp|pw|ticket)$)/i;
|
|
37
|
+
const SECRET_WORD_RE = /secret/i;
|
|
38
|
+
/**
|
|
39
|
+
* 「名字像凭据、其实不是」的排除——**必须锚定**:无锚点的子串排除会把 `provider_secret`(prov-id-er)、
|
|
40
|
+
* `model_secret`(mode-l)这类真凭据放过去。只认两种形态:
|
|
41
|
+
* - 整名是已知的路径/开关参数;
|
|
42
|
+
* - 以「凭据词 + 明确的非凭据后缀」结尾(`secret_id` / `token_type` / `max_tokens` …)。
|
|
43
|
+
*/
|
|
44
|
+
const KNOWN_NON_SECRET = new Set(['secretpath', 'expandsecretreferences', 'sshpass', 'tokenizer', 'passthrough', 'bypass']);
|
|
45
|
+
const NON_SECRET_SUFFIX_RE = /(?:secret|token|key)s?[-_]?(?:path|references?|names?|ids?|version|types?|mode|enabled|count|limit|length|ttl|expiry|expires(?:[-_]?(?:at|in))?)$|(?:^|[-_])(?:max|min|num|total|input|output|prompt|completion)[-_]?tokens$/i;
|
|
46
|
+
function isSecretName(name) {
|
|
47
|
+
const n = name.trim();
|
|
48
|
+
if (KNOWN_NON_SECRET.has(n.toLowerCase()) || NON_SECRET_SUFFIX_RE.test(n))
|
|
49
|
+
return false;
|
|
50
|
+
return SECRET_NAME_RE.test(n) || SECRET_WORD_RE.test(n);
|
|
51
|
+
}
|
|
52
|
+
function redactUrl(raw) {
|
|
53
|
+
let u;
|
|
54
|
+
try {
|
|
55
|
+
u = new URL(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
60
|
+
if (!/^https?:$/.test(u.protocol))
|
|
61
|
+
return raw;
|
|
62
|
+
if (u.username || u.password) {
|
|
63
|
+
u.username = MASK;
|
|
64
|
+
u.password = '';
|
|
65
|
+
}
|
|
66
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
67
|
+
if (isSecretName(key))
|
|
68
|
+
u.searchParams.set(key, MASK);
|
|
69
|
+
}
|
|
70
|
+
if (u.hash.length > 1)
|
|
71
|
+
u.hash = MASK; // fragment 常见 `#access_token=…`,对排错无用,整段打码
|
|
72
|
+
return u.toString().replace(/%5BREDACTED%5D/g, MASK);
|
|
73
|
+
}
|
|
74
|
+
function redactHeader(h) {
|
|
75
|
+
const i = h.indexOf(':');
|
|
76
|
+
if (i <= 0)
|
|
77
|
+
return h;
|
|
78
|
+
const name = h.slice(0, i).trim();
|
|
79
|
+
return SAFE_HEADER_RE.test(name) ? h : `${name}: ${MASK}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 返回脱敏后的 argv 副本(仅用于展示/排错;绝不改传给子进程的真参数)。
|
|
83
|
+
* 不覆盖:藏在 URL **路径段**里的凭据(webhook 型 URL)与 User-Agent 里夹带的值——这类请走请求头/请求体。
|
|
84
|
+
*/
|
|
85
|
+
function redactArgv(args) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let i = 0; i < args.length; i++) {
|
|
88
|
+
const a = args[i];
|
|
89
|
+
// --opt=value
|
|
90
|
+
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
91
|
+
if (eq > 0) {
|
|
92
|
+
const opt = a.slice(0, eq);
|
|
93
|
+
if (opt === '--url') {
|
|
94
|
+
out.push(`${opt}=${redactUrl(a.slice(eq + 1))}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (VALUE_IS_SECRET.has(opt)) {
|
|
98
|
+
out.push(`${opt}=${MASK}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (HEADER_OPTS.has(opt)) {
|
|
102
|
+
out.push(`${opt}=${redactHeader(a.slice(eq + 1))}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 选项与值分开
|
|
107
|
+
if (VALUE_IS_SECRET.has(a)) {
|
|
108
|
+
out.push(a);
|
|
109
|
+
if (i + 1 < args.length) {
|
|
110
|
+
out.push(MASK);
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (HEADER_OPTS.has(a)) {
|
|
116
|
+
out.push(a);
|
|
117
|
+
if (i + 1 < args.length) {
|
|
118
|
+
out.push(redactHeader(args[i + 1]));
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// 短选项簇:-Hfoo / -dbody / -uuser:pass / -sSu user:pass / -XPOST
|
|
124
|
+
if (/^-[^-]/.test(a) && a.length > 2) {
|
|
125
|
+
let j = 1;
|
|
126
|
+
while (j < a.length && !SHORT_WITH_VALUE.has(a[j]))
|
|
127
|
+
j++; // 跳过不带值的开关字母
|
|
128
|
+
if (j < a.length) {
|
|
129
|
+
const opt = `-${a[j]}`, head = a.slice(0, j + 1), rest = a.slice(j + 1);
|
|
130
|
+
const sticky = rest.length > 0; // 值粘在簇里,否则是下一个 argv
|
|
131
|
+
const value = sticky ? rest : args[i + 1];
|
|
132
|
+
if (VALUE_IS_SECRET.has(opt) || HEADER_OPTS.has(opt)) {
|
|
133
|
+
const masked = value === undefined ? undefined : (HEADER_OPTS.has(opt) ? redactHeader(value) : MASK);
|
|
134
|
+
if (sticky)
|
|
135
|
+
out.push(`${head}${masked}`);
|
|
136
|
+
else {
|
|
137
|
+
out.push(head);
|
|
138
|
+
if (masked !== undefined) {
|
|
139
|
+
out.push(masked);
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (/^https?:\/\//i.test(a)) {
|
|
148
|
+
out.push(redactUrl(a));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// 无 scheme 的 `user:pass@host…`(curl 接受)
|
|
152
|
+
out.push(a.replace(/^[^\s/@:]+:[^\s/@]+@/, `${MASK}@`));
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
/** 从 argv 里收集「字面值就是秘密」的串,供 scrub 在任意文本里逐字清除。 */
|
|
157
|
+
function secretLiterals(args) {
|
|
158
|
+
const red = redactArgv(args);
|
|
159
|
+
const lits = [];
|
|
160
|
+
for (let i = 0; i < args.length; i++) {
|
|
161
|
+
if (!red[i].includes(MASK))
|
|
162
|
+
continue; // 按「是否真的打了码」判,不按字符串是否相等(URL 规范化也会让它不等)
|
|
163
|
+
const a = args[i].replace(/^--url=/, '');
|
|
164
|
+
// 子进程 stderr 可能只回显参数的一段,所以除整串外还要收内部片段——但**只收名字像凭据的值**:
|
|
165
|
+
// 把 URL 里所有 k=v 都当秘密,会让 `prod` / `true` 这类普通词把报错原文洗得没法读。
|
|
166
|
+
if (/^https?:\/\//i.test(a)) {
|
|
167
|
+
try {
|
|
168
|
+
const u = new URL(a);
|
|
169
|
+
if (u.password)
|
|
170
|
+
lits.push(u.password, decodeURIComponent(u.password));
|
|
171
|
+
if (u.username && u.password)
|
|
172
|
+
lits.push(`${u.username}:${u.password}`);
|
|
173
|
+
for (const [k, v] of u.searchParams)
|
|
174
|
+
if (isSecretName(k))
|
|
175
|
+
lits.push(v);
|
|
176
|
+
if (u.hash.length > 1) { // fragment:`#access_token=…`
|
|
177
|
+
lits.push(u.hash.slice(1));
|
|
178
|
+
for (const [, v] of new URLSearchParams(u.hash.slice(1)))
|
|
179
|
+
if (v)
|
|
180
|
+
lits.push(v);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch { /* 非法 URL:redactUrl 也不会动它,走不到这里 */ }
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
lits.push(a); // 非 URL 的敏感参数(请求体 / -u / 认证头 / 代理):整串即秘密
|
|
187
|
+
for (const m of a.matchAll(/"([^"]*)"\s*:\s*"([^"]+)"/g))
|
|
188
|
+
if (isSecretName(m[1]))
|
|
189
|
+
lits.push(m[2]); // JSON 字段
|
|
190
|
+
for (const m of a.matchAll(/(?:^|&)([^=&\s]+)=([^&\s]+)/g)) { // form k=v
|
|
191
|
+
if (isSecretName(m[1])) {
|
|
192
|
+
lits.push(m[2]);
|
|
193
|
+
try {
|
|
194
|
+
lits.push(decodeURIComponent(m[2]));
|
|
195
|
+
}
|
|
196
|
+
catch { /* 裸 % */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const header = /^[A-Za-z0-9-]+:\s*(.+)$/.exec(a);
|
|
200
|
+
if (header) { // Header: value
|
|
201
|
+
lits.push(header[1].trim());
|
|
202
|
+
const scheme = /^(?:Bearer|Basic|Token)\s+(\S+)/i.exec(header[1].trim());
|
|
203
|
+
if (scheme)
|
|
204
|
+
lits.push(scheme[1]);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const up = /^[^\s/@:]*:([^\s@]+)(?:@|$)/.exec(a); // user:pass[@host]
|
|
208
|
+
if (up)
|
|
209
|
+
lits.push(up[1]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// 长的先替换,避免短片段把长串切碎后漏网;太短的不收(误伤普通文本)
|
|
213
|
+
return [...new Set(lits.filter((s) => s && s.length >= 4))].sort((x, y) => y.length - x.length);
|
|
214
|
+
}
|
|
215
|
+
/** 清洗任意文本:先按字面值,再按常见凭据形态(认证头、Bearer/Basic、URL 凭据、k=v、JSON 字段)。 */
|
|
216
|
+
function scrub(text, literals = []) {
|
|
217
|
+
let out = String(text ?? '');
|
|
218
|
+
for (const lit of [...literals].filter((s) => s && s.length >= 4).sort((a, b) => b.length - a.length)) {
|
|
219
|
+
out = out.split(lit).join(MASK);
|
|
220
|
+
const enc = encodeURIComponent(lit);
|
|
221
|
+
if (enc !== lit)
|
|
222
|
+
out = out.split(enc).join(MASK);
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
.replace(/((?:proxy-)?authorization\s*[:=]\s*)(?:(?:bearer|basic|token)\s+)?[^\s"'\\]+/gi, `$1${MASK}`)
|
|
226
|
+
.replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${MASK}`)
|
|
227
|
+
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:]+:[^\s/]*@/gi, `$1${MASK}@`) // 贪婪到最后一个 @:密码本身可含 @
|
|
228
|
+
// 三条「名字 → 值」规则统一用 isSecretName 判(与 argv 脱敏同一口径)
|
|
229
|
+
// 值类必须排除 `?`:否则非凭据键(`url=http://h/cb?token=X`)会把后面的凭据对整段吞进自己的值里原样返回。
|
|
230
|
+
.replace(/(^|[?&;,(\s"'])(-{0,2}[A-Za-z_][\w.-]*)(\s*=\s*)(?:"[^"]*"|'[^']*'|[^&?;,)\s"']+)/g, (m, pre, key, eq) => (isSecretName(key.replace(/^-+/, '')) ? `${pre}${key}${eq}${MASK}` : m))
|
|
231
|
+
.replace(/"([A-Za-z_][\w-]*)"(\s*:\s*)"[^"]*"/g, (m, key, sep) => (isSecretName(key) ? `"${key}"${sep}"${MASK}"` : m))
|
|
232
|
+
.replace(/\b((?:cookie|set-cookie|x-[a-z0-9-]*(?:key|token|secret|auth|session)[a-z0-9-]*|[a-z0-9-]*api-?key)\s*:\s*)[^\r\n"']+/gi, `$1${MASK}`)
|
|
233
|
+
// `name: value`(yaml / 单引号 dict)。值须像一个 token(≥6 且含数字或符号),否则 `password: must be …`
|
|
234
|
+
// 这类普通报错句子会被误洗。
|
|
235
|
+
.replace(/(^|[\s{,])(["']?)([A-Za-z_][\w-]*)\2(\s*:\s*)('[^']*'|[^\s,}"']+)/g, (m, pre, q, key, sep, val) => {
|
|
236
|
+
const bare = val.replace(/^'|'$/g, '');
|
|
237
|
+
const tokenish = bare.length >= 6 && /[\d_\-+/=.]/.test(bare);
|
|
238
|
+
return isSecretName(key) && tokenish ? `${pre}${q}${key}${q}${sep}${MASK}` : m;
|
|
239
|
+
})
|
|
240
|
+
.replace(/(\bsshpass\s+-p\s*)("[^"]*"|'[^']*'|\S+)/g, `$1${MASK}`);
|
|
241
|
+
}
|
|
242
|
+
/** 值是 URL 但不是请求目标的选项(代理 / referer)。 */
|
|
243
|
+
const URL_VALUED_OPTS = new Set(['-x', '--proxy', '--preproxy', '-e', '--referer']);
|
|
244
|
+
function hostOf(args) {
|
|
245
|
+
for (let i = 0; i < args.length; i++) {
|
|
246
|
+
const a = args[i].replace(/^--url=/, '');
|
|
247
|
+
if (!/^https?:\/\//i.test(a) || (i > 0 && URL_VALUED_OPTS.has(args[i - 1])))
|
|
248
|
+
continue;
|
|
249
|
+
try {
|
|
250
|
+
return new URL(a).host;
|
|
251
|
+
}
|
|
252
|
+
catch { /* 不是合法 URL:继续找 */ }
|
|
253
|
+
}
|
|
254
|
+
return 'unknown';
|
|
255
|
+
}
|
|
256
|
+
/** 会让 curl 把请求头/响应头/响应体细节写进输出的选项:响应头里的凭据(Set-Cookie 等)没法按字面值清洗,直接不许用。 */
|
|
257
|
+
function findVerboseOpt(args) {
|
|
258
|
+
for (let i = 0; i < args.length; i++) {
|
|
259
|
+
const a = args[i];
|
|
260
|
+
if (a.startsWith('--')) {
|
|
261
|
+
const name = a.split('=')[0];
|
|
262
|
+
// curl 接受长选项的唯一前缀(`--verbos`),所以按前缀判
|
|
263
|
+
if (/^--(verb|trace|incl|dump-h)/.test(name))
|
|
264
|
+
return name;
|
|
265
|
+
if (!a.includes('=') && (VALUE_IS_SECRET.has(name) || HEADER_OPTS.has(name) || URL_VALUED_OPTS.has(name)))
|
|
266
|
+
i++; // 跳过它的值
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!/^-[^-]/.test(a))
|
|
270
|
+
continue;
|
|
271
|
+
let j = 1;
|
|
272
|
+
for (; j < a.length; j++) {
|
|
273
|
+
if ('viD'.includes(a[j]))
|
|
274
|
+
return `-${a[j]}`;
|
|
275
|
+
if (SHORT_WITH_VALUE.has(a[j]))
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
if (j < a.length && j === a.length - 1)
|
|
279
|
+
i++; // 簇以带值选项结尾:下一个 argv 是值,跳过
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* 把 child_process 抛的 error 换成一个干净的新 Error。
|
|
285
|
+
* 只取 status/signal/code 与(清洗后的)stderr;**不复制** message/cmd/spawnargs/stdout/output。
|
|
286
|
+
*/
|
|
287
|
+
function sanitizeExecError(label, raw, literals = [], context = '') {
|
|
288
|
+
const e = (raw ?? {});
|
|
289
|
+
const parts = [];
|
|
290
|
+
if (typeof e.status === 'number')
|
|
291
|
+
parts.push(`exit=${e.status}`);
|
|
292
|
+
if (e.signal)
|
|
293
|
+
parts.push(`signal=${e.signal}`);
|
|
294
|
+
if (typeof e.code === 'string')
|
|
295
|
+
parts.push(`code=${e.code}`); // ENOENT / ETIMEDOUT / ENOBUFS…
|
|
296
|
+
if (context)
|
|
297
|
+
parts.push(context);
|
|
298
|
+
const stderr = scrub(e.stderr == null ? '' : String(e.stderr), literals).trim().slice(0, 2000);
|
|
299
|
+
return new Error(`${label} failed (${parts.join(' ') || 'unknown error'})${stderr ? `: ${stderr}` : ''}`);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* 同步跑 curl,返回 stdout。参数数组直传(不经 shell)。
|
|
303
|
+
* 失败时抛的 Error 不含 argv:只有退出码/信号/errno、目标主机名、清洗过的 curl stderr。
|
|
304
|
+
* `-sS`:静默进度条但保留 curl 自己的错误行(`curl: (7) Failed to connect…`),否则失败时两眼一抹黑。
|
|
305
|
+
*/
|
|
306
|
+
function runCurl(args, opts = {}) {
|
|
307
|
+
const verbose = findVerboseOpt(args);
|
|
308
|
+
if (verbose)
|
|
309
|
+
throw new Error(`runCurl: option ${verbose} is not allowed (it writes header/body details to the output)`);
|
|
310
|
+
try {
|
|
311
|
+
return (0, child_process_1.execFileSync)(opts.bin ?? 'curl', ['-sS', ...args], {
|
|
312
|
+
encoding: 'utf-8',
|
|
313
|
+
stdio: ['ignore', 'pipe', 'pipe'], // stderr 收进 error 对象(由我们清洗后再露出),不直通父进程
|
|
314
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
315
|
+
...(opts.timeoutMs ? { timeout: opts.timeoutMs } : {}),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
catch (raw) {
|
|
319
|
+
throw sanitizeExecError('curl', raw, secretLiterals(args), `host=${hostOf(args)}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
const
|
|
4
|
+
const safe_exec_1 = require("./safe-exec");
|
|
5
5
|
const db_utils_1 = require("./db-utils");
|
|
6
6
|
// 支持的服务列表(Infisical 路径为 /services/<service-name>)
|
|
7
7
|
const SUPPORTED_SERVICES = [
|
|
@@ -43,7 +43,11 @@ function isSupportedEnv(env) {
|
|
|
43
43
|
// NOTE: getInfisicalSecrets is kept local because it encodes secretPath (encodeURIComponent),
|
|
44
44
|
// unlike db-utils' raw-path variant; getGitHubVariable/getInfisicalConfig/getInfisicalToken are shared from db-utils.
|
|
45
45
|
function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
46
|
-
|
|
46
|
+
// runCurl:参数数组直传(不经 shell),失败时的报错不回显命令参数。
|
|
47
|
+
const response = (0, safe_exec_1.runCurl)([
|
|
48
|
+
`${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}`,
|
|
49
|
+
'-H', `Authorization: Bearer ${token}`,
|
|
50
|
+
]);
|
|
47
51
|
const data = JSON.parse(response);
|
|
48
52
|
const secrets = {};
|
|
49
53
|
for (const secret of data.secrets || []) {
|