Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/feflow-cli/src/core/logger/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ let keysFileContent: Partial<KeysFileContent> = {};
if (!keysFileContent.time || NOW_TIME - keysFileContent.time > 5184e6) {
const {
data: { result },
} = await axios.get(`http://log.feflowjs.com/api/v1/summary/getskey?rtx=${USER_NAME}`);
} = await axios.get(`https://log.feflowjs.com/api/v1/summary/getskey?rtx=${USER_NAME}`);
keysFileContent = {
time: NOW_TIME,
skey: result.skey,
Expand Down Expand Up @@ -71,7 +71,7 @@ async function send(logObj: LogObj | undefined, readData: string[]) {
// 清除数据
fs.writeFile(LOGGER_LOG_PATH, '', 'utf8', () => {});
const response = await axios.post(
'http://log.feflowjs.com/api/v1/log/save',
'https://log.feflowjs.com/api/v1/log/save',
{
plugin: loggerList[0].name,
data: JSON.stringify(loggerList),
Expand Down
8 changes: 6 additions & 2 deletions packages/feflow-cli/src/core/native/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,14 @@ export default (ctx: Feflow) => {
const { type, content } = universalUsage instanceof Function ? universalUsage() : universalUsage;

// case 1: 多语言情况下 yml 有 usage 属性时,执行对应的内容
// 安全修复:移除 shell:true,改用参数数组,防止 yml content 中的 shell 元字符注入
if (type === 'usage') {
spawn(content, {
const parts = (content as string).trim().split(/\s+/);
const cmd = parts[0];
const args = parts.slice(1);
spawn(cmd, args, {
stdio: 'inherit',
shell: true,
shell: false,
windowsHide: true,
});
return;
Expand Down
7 changes: 7 additions & 0 deletions packages/feflow-cli/src/shared/git-askpass.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh
# git-askpass.sh — feflow 安全凭证助手
# 通过环境变量传递 Git 认证凭证,避免凭证出现在 URL 或命令行参数中
case "$1" in
Username*) echo "${FEFLOW_GIT_USERNAME}" ;;
Password*) echo "${FEFLOW_GIT_PASSWORD}" ;;
esac
209 changes: 117 additions & 92 deletions packages/feflow-cli/src/shared/git.ts
Original file line number Diff line number Diff line change
@@ -1,139 +1,164 @@
import spawn from 'cross-spawn';
import rp from 'request-promise';
import { getURL } from './url';
import path from 'path';

// ─── 安全说明 ──────────────────────────────────────────────────────────────────
// 旧版实现存在两个高危漏洞:
// 1. 协议降级:将 git@host:path 改写为 http://host/path,使流量走明文 HTTP
// 2. URL 内联凭证:将账号密码拼入 URL(http://user:pass@host),
// 导致凭证出现在进程参数列表、日志、HTTPS 降级后的中间人可见流量中
// 修复方案:
// - transformUrl 只允许 https:// / git@ 协议,拒绝 http://
// - 凭证通过 GIT_ASKPASS 环境变量传递,不再内联进 URL
// ──────────────────────────────────────────────────────────────────────────────

let gitAccount: {
username: string;
password: string;
};
let serverUrl: string;

export function setServerUrl(url: string) {
serverUrl = url;
}

function getHostname(url: string): string {
if (/https?/.test(url)) {
if (/^https?:\/\//.test(url)) {
const [, , match = ''] = url.match(/^http(s)?:\/\/(.*?)\//) || [];
return match.split('@').pop() || '';
}
const [, hostname = ''] = url.match(/@(.*):/) || [];
return hostname;
}

async function prepareAccount() {
if (gitAccount) {
return;
}
const url = getURL(serverUrl, 'apply/getlist?name=0');
if (!url) {
return;
/**
* 安全地转换仓库 URL:
* - 拒绝 http:// 协议(防止协议降级攻击)
* - https:// 保持原样;git@ 在 SSH 不可用时转为 https://
* - 不再将凭证内联到 URL 中
*
* @param url 原始仓库 URL(https:// 或 git@)
* @returns 安全的仓库 URL(string)
* @throws 若 URL 使用不允许的协议则抛出错误
*/
export async function transformUrl(url: string): Promise<string> {
// 拒绝裸 http:// 协议(含内联凭证形式 http://user:pass@host)
if (/^http:\/\//.test(url)) {
throw new Error(`Insecure repository URL rejected (http is not allowed, use https or git@): ${url}`);
}
const options = {
url,
method: 'GET',
};
return rp(options)
.then((response: string) => {
const data = JSON.parse(response);
if (data.account) {
gitAccount = data.account;

if (/^https:\/\//.test(url) || /^git@/.test(url)) {
const hostname = getHostname(url);
// 检测 SSH 可用性,超时 3s 回退到 https
const sshOk = await isSupportSSH(`git@${hostname}`);
if (sshOk) {
// SSH 可用:https:// → git@host:path
if (/^https:\/\//.test(url)) {
return url.replace(/^https:\/\/([^/]+)\//, 'git@$1:');
}
})
.catch(() => {});
return url;
} else {
// SSH 不可用:git@ → https://,https:// 保持不变
if (/^git@/.test(url)) {
return url.replace(/^git@([^:]+):/, 'https://$1/');
}
return url;
}
}

throw new Error(`Unsupported repository protocol: ${url}`);
}

export async function transformUrl(url: string, account?: any): Promise<any> {
if (account) {
gitAccount = account;
} else {
await prepareAccount();
}
const hostname = getHostname(url);
let transformedUrl;
if (/https?/.test(url)) {
transformedUrl = url;
} else {
transformedUrl = url.replace(`git@${hostname}:`, `http://${hostname}/`);
let _sshCache: Record<string, boolean> = {};

async function isSupportSSH(url: string): Promise<boolean> {
if (_sshCache[url] !== undefined) {
return _sshCache[url];
}
if (gitAccount) {
const { username, password } = gitAccount;
return transformedUrl.replace(/https?:\/\/(.*?(:.*?)?@)?/, `http://${username}:${password}@`);
try {
const result = await Promise.race([
Promise.resolve(spawn.sync('ssh', ['-vT', url], { windowsHide: true })),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
]) as ReturnType<typeof spawn.sync>;
const ok = /Authentication succeeded/.test(result?.stderr?.toString() ?? '');
_sshCache[url] = ok;
return ok;
} catch {
_sshCache[url] = false;
return false;
}
return transformedUrl;
}

export async function clearGitCert(url: string) {
const { username } = gitAccount;
if (!username) {
return;
/**
* 构建安全的 Git 环境变量:
* 通过 GIT_ASKPASS 脚本传递凭证,不将账号密码内联到 URL 或暴露在命令行参数中。
*
* @param account 可选账号信息 { username, password }
* @returns 含 GIT_ASKPASS 的环境变量对象
*/
export function buildGitEnv(account?: { username: string; password: string }): NodeJS.ProcessEnv {
if (!account) {
return process.env;
}
let finalUrl = '';
if (!/https?:\/\/(.*?(:.*?)?@)/.test(url)) {
finalUrl = await transformUrl(url);
}
return new Promise((resolve, reject) => {
const child = spawn('git', ['credential', 'reject'], {
windowsHide: true,
timeout: 60 * 1000,
});
child.stdin?.write(`url=${finalUrl}`);
child.stdin?.end();
child.on('close', (code) => {
resolve(code);
});
child.on('error', (err) => {
reject(err);
});
});
return {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: path.join(__dirname, 'git-askpass.sh'),
FEFLOW_GIT_USERNAME: account.username,
FEFLOW_GIT_PASSWORD: account.password,
};
}

export async function clearGitCertByPath(repoPath: string) {
const ret = spawn.sync('git', ['config', '--get', 'remote.origin.url'], {
windowsHide: true,
cwd: repoPath,
});
const url = ret?.stdout?.toString().trim();
return clearGitCert(url);
/**
* 清除 git credential 缓存(使用 git credential reject)。
* 修复后凭证不再内联进 URL,此函数仅在 url 包含凭证时有操作意义,
* 新流程下可直接返回。
*/
export async function clearGitCert(_url: string): Promise<void> {
// 新流程凭证通过 GIT_ASKPASS 传递,不内联进 URL,无需 credential reject
return;
}

export async function download(url: string, tag: string, filepath: string): Promise<any> {
export async function clearGitCertByPath(_repoPath: string): Promise<void> {
return;
}

/**
* 安全下载仓库:URL 不含凭证,凭证通过环境变量传递。
*
* @param url 原始仓库 URL
* @param tag 要 checkout 的 tag
* @param filepath 本地目标路径
* @param account 可选 Git 账号
*/
export async function download(
url: string,
tag: string,
filepath: string,
account?: { username: string; password: string }
): Promise<void> {
const cloneUrl = await transformUrl(url);
const env = buildGitEnv(account);

console.log('clone from', url);
return new Promise((resolve, reject) => {
const child = spawn('git', ['clone', '-b', tag, '--progress', '--depth', '1', cloneUrl, filepath], {
stdio: 'pipe',
windowsHide: true,
});
const child = spawn(
'git',
['clone', '-b', tag, '--progress', '--depth', '1', cloneUrl, filepath],
{ stdio: 'pipe', windowsHide: true, env }
);
let doneFlag = false;
child.stderr?.on('data', (d) => {
if (doneFlag) {
return;
}
if (doneFlag) return;
if (d?.toString()?.startsWith('Note:') || d?.toString()?.startsWith('注意')) {
doneFlag = true;
return;
}
process.stderr.write(d);
});
child.stdout?.on('data', (d) => {
if (doneFlag) {
return;
}
if (doneFlag) return;
process.stdout.write(d);
});
child.on('close', (code) => {
if (code === 0) {
resolve(0);
} else {
reject(code);
}
});
child.on('error', (err) => {
reject(err);
if (code === 0) resolve();
else reject(code);
});
})
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.finally(() => clearGitCert(cloneUrl));
child.on('error', reject);
});
}
3 changes: 2 additions & 1 deletion packages/feflow-report/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export const HOOK_TYPE_BEFORE = 'before';
*/
export const HOOK_TYPE_AFTER = 'after';

const BASIC_URL = 'http://api.feflowjs.com';
// 安全修复:上报地址改为 HTTPS,防止遥测数据明文传输
const BASIC_URL = 'https://api.feflowjs.com';

export const REPORT_URL = `${BASIC_URL}/api/v1/report/command`;

Expand Down
Loading