文件整理 #4
@@ -9,7 +9,7 @@ inputs:
|
|||||||
description: '操作 Gitea Commit API 的 Token'
|
description: '操作 Gitea Commit API 的 Token'
|
||||||
required: false
|
required: false
|
||||||
model:
|
model:
|
||||||
description: '使用的 AI 模型'
|
description: '使用的 AI 模型,僅允許英數字、點、底線與連字號'
|
||||||
required: false
|
required: false
|
||||||
runs:
|
runs:
|
||||||
|
admin marked this conversation as resolved
|
|||||||
using: 'docker'
|
using: 'docker'
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Bard
**問題**:把 Docker Action 的 image 參照改成小寫 `dockerfile`,讓原本業界慣用的 `Dockerfile` 檔名失去辨識度;這種大小寫改動會讓人讀配置時多停一下,也讓專案風格顯得不一致。
**建議**:把檔名與 `action.yml` 的 `runs.image` 都改回慣用的 `Dockerfile`,維持 Docker 生態的標準寫法。
|
|||||||
|
|||||||
@@ -42,6 +42,16 @@ export const LLM_PROVIDER = 'cliproxyapi';
|
|||||||
|
|
||||||
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
||||||
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
||||||
|
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:這裡只限制字元種類,卻還放行 `.` 與 `/`,因此像 `../foo`、`foo/../../bar` 這類路徑式字串仍可通過。攻擊者只要能控制 `inputs.model` 或 `CLI_PROXY_API_MODEL`,就能把惡意 model 值送進 CLIProxyAPI;若後端拿 model 名稱去拼路徑、呼叫指令或做檔名查找,這個輸入就可能被拿來做路徑穿越或指令注入。
**建議**:不要只做字元白名單,應改成明確白名單比對可用模型 slug,並額外拒絕 `..`、前導/結尾 `/`、連續 `/`、反斜線與控制字元;如果可行,直接用 `/v1/models` 回傳清單做嚴格選擇,而不是接受任意形狀的字串。
|
|||||||
|
const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Assassin
**問題**:正則表達式 `MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/` 不允許 `/` 字符。某些合法的模型名稱格式(如 `openrouter/openai/gpt-4o` 或 `providers/openai/models/gpt-4o`)會被拒絕,導致功能受限。雖然不是直接的安全漏洞,但可能造成合法請求被誤判為異常。
**建議**:評估是否需要在正則表達式中允許 `/` 字符。若允許,應同時確保不會引入新的安全風險(例如路徑穿越攻擊)。改為 `/^[A-Za-z0-9._/-]+$/` 並增加單元測試確認邊界情況。
|
|||||||
|
function normalizeModelName(raw) {
|
||||||
|
const model = String(raw || '').trim();
|
||||||
|
if (!model) return { model: null, modelError: null };
|
||||||
|
if (!MODEL_NAME_RE.test(model)) {
|
||||||
|
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' };
|
||||||
|
}
|
||||||
|
return { model, modelError: null };
|
||||||
|
}
|
||||||
|
|
||||||
let _insecureHttpsAgent = null;
|
let _insecureHttpsAgent = null;
|
||||||
/**
|
/**
|
||||||
@@ -67,26 +77,28 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
|
|||||||
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
|
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
|
||||||
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
|
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
|
||||||
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
|
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
|
||||||
* 則交由 CLIProxyAPI 自動選擇模型,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY`
|
* 則交由 CLIProxyAPI 自動選擇模型。若提供的名稱含非法字元,會被視為無效並於
|
||||||
* 作為存取金鑰(會 trim)。
|
* `modelError` 回報,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。
|
||||||
*
|
*
|
||||||
* 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、
|
* 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、
|
||||||
* `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。
|
* `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。
|
||||||
*
|
*
|
||||||
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }}
|
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), modelError: (string|null), command: null }}
|
||||||
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
|
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
|
||||||
*/
|
*/
|
||||||
export function getLLMConfig() {
|
export function getLLMConfig() {
|
||||||
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
|
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
|
||||||
const model = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
|
const rawModel = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
|
||||||
|
const { model, modelError } = normalizeModelName(rawModel);
|
||||||
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
|
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
|
||||||
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null };
|
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model, modelError, command: null };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: LLM_PROVIDER,
|
provider: LLM_PROVIDER,
|
||||||
apiKeys: apiKey ? [apiKey] : [],
|
apiKeys: apiKey ? [apiKey] : [],
|
||||||
baseURL,
|
baseURL,
|
||||||
model: model || null,
|
model,
|
||||||
|
modelError,
|
||||||
command: null,
|
command: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,8 +209,9 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
|||||||
*/
|
*/
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
const { provider, baseURL, model } = cfg;
|
const { provider, baseURL, model, modelError } = cfg;
|
||||||
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
|
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
|
||||||
|
if (modelError) throw new Error(modelError);
|
||||||
|
|
||||||
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
|
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,9 @@ export async function fetchLLMModels({
|
|||||||
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
||||||
*/
|
*/
|
||||||
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
|
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
|
||||||
const { provider, command, model } = getLLMConfig();
|
const { provider, command, model, modelError } = getLLMConfig();
|
||||||
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
|
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
|
||||||
|
if (modelError) return { ok: false, provider, command, model, error: modelError };
|
||||||
|
|
||||||
if (provider === 'cliproxyapi') {
|
if (provider === 'cliproxyapi') {
|
||||||
const models = await fetchLLMModelsFn();
|
const models = await fetchLLMModelsFn();
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(cfg.model, 'gpt-5.4-mini');
|
assert.equal(cfg.model, 'gpt-5.4-mini');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid model names', () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
||||||
|
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
|
||||||
|
assert.equal(cfg.model, null);
|
||||||
|
assert.match(cfg.modelError, /無效的 model 參數/);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null provider when CLI_PROXY_API is missing', () => {
|
it('returns null provider when CLI_PROXY_API is missing', () => {
|
||||||
process.env.MODEL = 'gpt-5.5';
|
process.env.MODEL = 'gpt-5.5';
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ describe('chat - CLIProxyAPI', async () => {
|
|||||||
await assert.rejects(() => chat('sys', 'user'), /401/);
|
await assert.rejects(() => chat('sys', 'user'), /401/);
|
||||||
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
|
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when the configured model name is invalid', async () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
||||||
|
|
||||||
|
await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chatJSON', async () => {
|
describe('chatJSON', async () => {
|
||||||
|
|||||||
@@ -208,6 +208,20 @@ describe('verifyLLM', () => {
|
|||||||
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
|
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
|
||||||
assert.match(result.error, /gpt-9-imaginary/);
|
assert.match(result.error, /gpt-9-imaginary/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails when the configured model name is invalid', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
||||||
|
|
||||||
|
const result = await verifyLLM({
|
||||||
|
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.provider, 'cliproxyapi');
|
||||||
|
assert.match(result.error, /無效的 model 參數/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runPreflight', () => {
|
describe('runPreflight', () => {
|
||||||
|
|||||||
嚴重等級:🟡 警告
審查員:Assassin
問題:AI 模型參數(
model)為使用者輸入但未驗證。攻擊者可透過 PR workflow 傳入任意字符串,縱然後續經 JSON 序列化理論上應轉義,仍增加了攻擊面且難以追蹤輸入來源。建議:在 action.yml 中對
model輸入進行描述性限制(說明只接受特定格式),並在 src/config.js 的getLLMConfig()加上白名單驗證或正則表達式檢查,拒絕包含特殊字符的模型名稱(如單引號、反斜線、括號等)。例:/^[a-zA-Z0-9._-]+$/。嚴重等級:🟡 警告
審查員:Assassin
問題:action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符(如
gpt-4; rm -rf /),這些惡意輸入會先被寫入環境變數CLI_PROXY_API_MODEL,才在 Node.js 代碼中被驗證。違反了「最小信任原則」。建議:在 action.yml 中的 inputs.model 新增驗證限制(GitHub Actions 層面無原生驗證機制,但可在文檔中強調風險,並確保 Node.js 驗證實作完備)。或改為使用
choices列表限制可選值。目前的 Node.js 驗證雖然有效,但應在 GitHub Actions 文檔中明確說明:只有英數字、點、底線、連字號的 model 值才會被接受,其他值會被拒絕並導致工作流失敗。