refactor: calculate-version 由 bash 改寫為 Node.js #5

Merged
admin merged 12 commits from ai-review-resolve/develop-20260626-104052 into develop 2026-06-26 03:00:32 +00:00
5 changed files with 72 additions and 51 deletions
Showing only changes of commit fdf33b41d8 - Show all commits
+2 -2
View File
@@ -9,8 +9,8 @@
FROM node:lts-alpine FROM node:lts-alpine
# entrypoint.sh 以 bash 撰寫,Alpine 預設無 bash 需另行安裝 # entrypoint.sh 以 bash 撰寫,Alpine 預設無 bash 需另行安裝
# --no-cache 不保留 apk 索引快取以縮小映像;--no-check-certificate 略過憑證檢查 (需人工確認:略過憑證驗證有安全風險) # --no-cache 不保留 apk 索引快取以縮小映像(保留 TLS 憑證檢查以防中間人攻擊)
RUN apk add --no-cache --no-check-certificate bash RUN apk add --no-cache bash
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔴 嚴重
審查員:Assassin
問題:使用了 --no-check-certificate 參數下載軟體包,這會停用 TLS 憑證檢查。攻擊者若能進行中間人攻擊(MITM),即可攔截連線並將惡意的套件注入到映像檔中。
建議:移除 --no-check-certificate。若確實有無法解決的憑證問題,應排查來源網域的信任鏈或安裝正確的憑證,而不是直接關閉安全機制。

**嚴重等級**:🔴 嚴重 **審查員**:Assassin **問題**:使用了 `--no-check-certificate` 參數下載軟體包,這會停用 TLS 憑證檢查。攻擊者若能進行中間人攻擊(MITM),即可攔截連線並將惡意的套件注入到映像檔中。 **建議**:移除 `--no-check-certificate`。若確實有無法解決的憑證問題,應排查來源網域的信任鏈或安裝正確的憑證,而不是直接關閉安全機制。
# 複製容器進入點腳本至根目錄。 # 複製容器進入點腳本至根目錄。
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
+17 -3
View File
@@ -42,18 +42,32 @@ function normalizeBetaFlag(value) {
return value === 'true'; return value === 'true';
} }
// 驗證字串為合法的 http/https URL,否則拋出錯誤(避免指向非預期協定或格式錯誤的位址)
function assertHttpUrl(name, value) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`${name} 格式錯誤,必須為合法的 URL`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${name} 必須使用 http 或 https 協定`);
}
}
/** /**
* 從環境變數載入並驗證執行所需的設定。 * 從環境變數載入並驗證執行所需的設定。
* *
* GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤; * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL
* GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 * 另需為合法的 http/https URLGITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。
* *
* @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。 * @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。
* @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。 * @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。
* @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定時拋出。 * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定,或 GITEA_SERVER_URL 非合法 http/https URL 時拋出。
*/ */
function loadConfig(env = process.env) { function loadConfig(env = process.env) {
const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL);
assertHttpUrl('GITEA_SERVER_URL', serverUrl);
const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY);
Review

嚴重等級🟡 警告
審查員:Assassin
問題:對 GITEA_REPOSITORY 環境變數缺乏輸入驗證。由於此值會直接拼接於 API URL 中(見 app/index.js:37),若攻擊者傳入特殊字元或路徑穿越字元(如 ../),可能導致 API 請求路徑異常,甚至造成非預期的 API 端點存取。
建議:增加格式驗證機制,使用嚴格的正則表達式限制 GITEA_REPOSITORY 格式(例如確保只包含合法的 repo 名稱字元:^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$),拒絕任何不符合規範的輸入。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:對 GITEA_REPOSITORY 環境變數缺乏輸入驗證。由於此值會直接拼接於 API URL 中(見 app/index.js:37),若攻擊者傳入特殊字元或路徑穿越字元(如 `../`),可能導致 API 請求路徑異常,甚至造成非預期的 API 端點存取。 **建議**:增加格式驗證機制,使用嚴格的正則表達式限制 GITEA_REPOSITORY 格式(例如確保只包含合法的 repo 名稱字元:`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$`),拒絕任何不符合規範的輸入。
const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN;
const isBeta = normalizeBetaFlag(env.IS_BETA); const isBeta = normalizeBetaFlag(env.IS_BETA);
+42 -37
View File
@@ -9,52 +9,57 @@ const { writeOutput } = require('./output');
/** /**
* Action 進入點:協調設定載入、release 取得、版本號計算與輸出寫出。 * Action 進入點:協調設定載入、release 取得、版本號計算與輸出寫出。
* *
* 流程:印出「參數檢查」區段並呼叫 loadConfig() 載入設定(失敗則以 logger.fail * 失敗時(loadConfig 或 fetchReleases 拋錯)直接向外拋出例外,由呼叫端決定如何結束,
* 回報並結束);印出設定值(token 以 *** 或「未提供」顯示);組出 release URL 並 * 以利單元測試覆蓋失敗路徑。相依模組可透過 deps 注入,預設使用各兄弟模組的實作。
* 以 fetchReleases() 取得 release 清單(失敗則以 logger.fail 回報並結束);以
* calculateVersion() 計算最新版與新版號,最後以 writeOutput('version', version)
* 寫出 version 輸出。
* *
* Side effects:透過 logger 輸出至 stdout/stderr、失敗時使 process 以失敗狀態 * @param {Object} [deps={}] - 可注入的相依,供測試替換。
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Bard
問題:此函式註解過於詳盡描述執行流程(如 logger.fail、writeOutput),應專注於描述函式功能而非具體實作步驟。
建議:簡化為:/** Action 進入點:執行版本計算並寫入輸出。 */

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:此函式註解過於詳盡描述執行流程(如 logger.fail、writeOutput),應專注於描述函式功能而非具體實作步驟。 **建議**:簡化為:/** Action 進入點:執行版本計算並寫入輸出。 */
* 結束、並寫入 GITHUB_OUTPUT。此函式於模組載入時即被呼叫 * @param {Function} [deps.loadConfig] - 載入設定的函式
* * @param {Function} [deps.fetchReleases] - 取得 release 的函式。
* @returns {Promise<void>} * @param {Function} [deps.calculateVersion] - 計算版本號的函式。
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:主邏輯函式 main 處理了 loadConfigfetchReleases 的例外情境,但缺乏針對這些失敗路徑的整合測試,無法確保錯誤發生時流程能正確終止。
建議:建議編寫整合測試,透過 mock 相關依賴(如 loadConfigfetchReleases)來模擬錯誤,並驗證 main 是否正確觸發錯誤處理機制。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:主邏輯函式 `main` 處理了 `loadConfig` 與 `fetchReleases` 的例外情境,但缺乏針對這些失敗路徑的整合測試,無法確保錯誤發生時流程能正確終止。 **建議**:建議編寫整合測試,透過 mock 相關依賴(如 `loadConfig`、`fetchReleases`)來模擬錯誤,並驗證 `main` 是否正確觸發錯誤處理機制。
* @param {Function} [deps.writeOutput] - 寫出 output 的函式。
* @param {{section:Function, info:Function, error:Function}} [deps.log] - log 記錄器。
* @returns {Promise<string>} 計算出的版本號。
*/ */
async function main() { async function main(deps = {}) {
logger.section('參數檢查'); const {
loadConfig: loadConfigFn = loadConfig,
fetchReleases: fetchReleasesFn = fetchReleases,
calculateVersion: calculateVersionFn = calculateVersion,
writeOutput: writeOutputFn = writeOutput,
log = logger,
} = deps;
let config; log.section('參數檢查');
try {
config = loadConfig();
} catch (error) {
logger.fail(error.message);
return;
}
logger.info(`GITEA_SERVER_URL=${config.serverUrl}`); const config = loadConfigFn();
logger.info(`GITEA_REPOSITORY=${config.repository}`); log.info(`GITEA_SERVER_URL=${config.serverUrl}`);
logger.info(config.token ? 'GITEA_TOKEN=***' : 'GITEA_TOKEN=未提供'); log.info(`GITEA_REPOSITORY=${config.repository}`);
logger.info(`IS_BETA=${config.isBeta}`); log.info(config.token ? 'GITEA_TOKEN=***' : 'GITEA_TOKEN=未提供');
log.info(`IS_BETA=${config.isBeta}`);
logger.section('取得版本資料'); log.section('取得版本資料');
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Assassin
問題:Action 未對 GITEA_SERVER_URL 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,進而誘使 Action 將 GITEA_TOKEN 傳送至攻擊者伺服器,導致敏感憑證外洩。
建議:在 app/config.jsloadConfig 中,對 GITEA_SERVER_URL 進行嚴格驗證,確保其格式正確(例如開頭必須為 https://)且符合預期的網域白名單(若適用)。

**嚴重等級**:🔴 嚴重 **審查員**:Assassin **問題**:Action 未對 `GITEA_SERVER_URL` 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,進而誘使 Action 將 `GITEA_TOKEN` 傳送至攻擊者伺服器,導致敏感憑證外洩。 **建議**:在 `app/config.js` 的 `loadConfig` 中,對 `GITEA_SERVER_URL` 進行嚴格驗證,確保其格式正確(例如開頭必須為 `https://`)且符合預期的網域白名單(若適用)。
const releaseUrl = `${config.serverUrl}/api/v1/repos/${config.repository}/releases`; const releaseUrl = `${config.serverUrl}/api/v1/repos/${config.repository}/releases`;
logger.info(`RELEASE_URL=${releaseUrl}`); log.info(`RELEASE_URL=${releaseUrl}`);
let releases; const releases = await fetchReleasesFn(releaseUrl, { token: config.token, logger: log });
try {
releases = await fetchReleases(releaseUrl, { token: config.token, logger }); const { latest, version } = calculateVersionFn(releases, config.isBeta);
} catch (error) { log.info(`LATEST_VERSION=${latest}`);
logger.fail(error.message);
return; log.section('計算版本號');
log.info(`NEW_VERSION=${version}`);
writeOutputFn('version', version);
return version;
} }
const { latest, version } = calculateVersion(releases, config.isBeta); // 僅在被直接執行時啟動(被 require/測試載入時不自動執行),失敗則在頂層回報並以狀態碼 1 結束
logger.info(`LATEST_VERSION=${latest}`); if (require.main === module) {
main().catch((error) => {
logger.section('計算版本號'); logger.error(error.message);
logger.info(`NEW_VERSION=${version}`); process.exit(1);
writeOutput('version', version); });
} }
main(); module.exports = { main };
+5 -8
View File
2
@@ -28,18 +28,15 @@ function info(message) {
} }
/** /**
* 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),並以狀態碼 1 * 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。
* 立即終止整個行程。用於發生無法復原的錯誤時中止執行。
* *
* 注意:此函式呼叫 `process.exit(1)`,正常情況下不會回傳給呼叫端, * 僅負責輸出,不終止行程;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。
* 其後的程式碼不會被執行。
* *
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:註解提及 process.exit(1) 後續程式碼不會執行,此為程式語言基本常識,屬於冗餘註解。
建議:移除此行註解,讓程式碼更精簡。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:註解提及 process.exit(1) 後續程式碼不會執行,此為程式語言基本常識,屬於冗餘註解。 **建議**:移除此行註解,讓程式碼更精簡。
* @param {string} message - 要輸出的錯誤描述內容。 * @param {string} message - 要輸出的錯誤描述內容。
* @returns {never} 不會正常回傳(行程會被終止)。 * @returns {void}
*/ */
function fail(message) { function error(message) {
process.stderr.write(`[error] ${message}\n`); process.stderr.write(`[error] ${message}\n`);
process.exit(1);
} }
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Leo
問題:fail 函數在內部直接呼叫 process.exit(1),這會導致單元測試或呼叫此函數的程式無法攔截錯誤進行復原,且會直接終止整個 Node.js 行程,測試時會導致測試 runner 直接崩潰。
建議:建議將 fail 函數改為只負責輸出錯誤訊息並拋出例外(throw Error),由最外層的 main 函數負責攔截並決定

**嚴重等級**:🔴 嚴重 **審查員**:Leo **問題**:fail 函數在內部直接呼叫 process.exit(1),這會導致單元測試或呼叫此函數的程式無法攔截錯誤進行復原,且會直接終止整個 Node.js 行程,測試時會導致測試 runner 直接崩潰。 **建議**:建議將 fail 函數改為只負責輸出錯誤訊息並拋出例外(throw Error),由最外層的 main 函數負責攔截並決定
module.exports = { section, info, fail }; module.exports = { section, info, error };
+6 -1
View File
@@ -47,7 +47,12 @@ async function fetchReleases(baseUrl, options = {}) {
throw new Error(`release API 請求失敗 (page=${page})`); throw new Error(`release API 請求失敗 (page=${page})`);
} }
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Rogue
問題:浪費 CPU 週期在手動處理 JSON 解析。response.text()JSON.parse() 的效能比 response.json() 慢得多,且造成不必要的字串記憶體分配。
建議:直接使用 await response.json()

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:浪費 CPU 週期在手動處理 JSON 解析。`response.text()` 再 `JSON.parse()` 的效能比 `response.json()` 慢得多,且造成不必要的字串記憶體分配。 **建議**:直接使用 `await response.json()`。
const text = await response.text(); let text;
try {
text = await response.text();
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Mage
問題:fetch 的 response.text() 方法可能因連線中斷等原因拋出例外,且目前未被包裹在 try-catch 區塊中,若發生錯誤將無法提供明確的 API 頁碼上下文。
建議:將 response.text() 以及後續的 JSON.parse 邏輯整合進現有的 try-catch 區塊,確保錯誤處理能準確捕捉並包含頁碼 (page) 資訊。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:fetch 的 response.text() 方法可能因連線中斷等原因拋出例外,且目前未被包裹在 try-catch 區塊中,若發生錯誤將無法提供明確的 API 頁碼上下文。 **建議**:將 response.text() 以及後續的 JSON.parse 邏輯整合進現有的 try-catch 區塊,確保錯誤處理能準確捕捉並包含頁碼 (page) 資訊。
} catch (error) {
throw new Error(`release API 回應讀取失敗 (page=${page}): ${error.message}`);
}
// 空字串或 null 代表已無更多資料 // 空字串或 null 代表已無更多資料
if (!text || text === 'null') { if (!text || text === 'null') {
2