diff --git a/src/index.js b/src/index.js index 8131148..95b9493 100644 --- a/src/index.js +++ b/src/index.js @@ -210,7 +210,7 @@ async function main() { }; /** * 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立), - * 並把 `issueBuffer` 內暫存的情境留言依序寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。 + * 並把 `issueBuffer` 內暫存的情境留言批次寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。 * 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。 * * @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選); @@ -224,9 +224,9 @@ async function main() { labels: labelIds, }); log('建問題', 'INF', `已建立追蹤 issue #${issue.number},寫入 ${issueBuffer.length} 則情境留言。`); - for (const body of issueBuffer) { - await gitea.createCommentOnIssue(ctx, issue.number, body); - } + await Promise.all( + issueBuffer.map((body) => gitea.createCommentOnIssue(ctx, issue.number, body)), + ); issueBuffer.length = 0; }; diff --git a/src/lib/gitrepo.js b/src/lib/gitrepo.js index 7eda1e1..a9278a3 100644 --- a/src/lib/gitrepo.js +++ b/src/lib/gitrepo.js @@ -4,6 +4,34 @@ const { execFileSync } = require('child_process'); // git 操作工具:一律以 execFileSync 呼叫 git(不經 shell,避免注入),輸出以 UTF-8 回傳。 +/** + * 驗證遠端分支名稱可安全用於 refspec 與 refs/remotes/origin/*。 + * + * @param {string} refName - 使用者或事件 payload 提供的分支名稱。 + * @param {string} fieldName - 錯誤訊息中的欄位名稱。 + * @returns {string} 原樣回傳通過驗證的分支名稱。 + * @throws {Error} 分支名稱空白、含路徑穿越,或不符合 git 分支 ref 規則時拋出。 + * @remarks + * 使用情境:`resolveMergeBase` 的 `baseRef` 與 `commitAndPushFindings` 的 + * `headRef` 會被組進 refspec;先驗證可避免惡意 payload 影響本地 refs 路徑。 + */ +function assertSafeBranchRef(refName, fieldName) { + const value = String(refName || '').trim(); + if (!value) throw new Error(`${fieldName} 不可為空。`); + if (value.includes('..') || value.startsWith('/') || value.endsWith('/') || value.includes('\\')) { + throw new Error(`${fieldName} 不是安全的分支名稱:${value}`); + } + try { + execFileSync('git', ['check-ref-format', '--branch', value], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + throw new Error(`${fieldName} 不是合法的 git 分支名稱:${value}`); + } + return value; +} + /** * 同步執行 git 指令並回傳原始 stdout 輸出。 * @@ -102,6 +130,7 @@ function latestCommitSubject(cwd) { * 避免把 base 分支後續演進誤算進 diff。 */ function resolveMergeBase(cwd, baseRef) { + baseRef = assertSafeBranchRef(baseRef, 'baseRef'); const remoteBase = `origin/${baseRef}`; const diagnostics = []; // 執行一個 fetch 策略並記錄成敗(只記策略名與成敗,不含 git 原始輸出,避免洩漏遠端資訊)。 @@ -251,6 +280,7 @@ function fileLastUpdatedIso(cwd, file) { * 例外把命令列(含 token)回顯到 CI log 或程序清單。 */ function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, serverUrl, repository }) { + headRef = assertSafeBranchRef(headRef, 'headRef'); const current = gitTrim(cwd, 'rev-parse', 'HEAD'); if (headSha && current !== headSha) { git(cwd, 'checkout', '--detach', headSha); @@ -294,17 +324,22 @@ function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, s * * @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。 * @param {string} remoteUrl - 不含帳密的遠端 URL(形如 `https://host/owner/repo.git`)。 - * @param {string} secret - 具 push 權限的 token/PAT(作為 Basic 認證的密碼)。 + * @param {string} token - 具 push 權限的 token/PAT(作為 Basic 認證的密碼)。 * @param {string} refspec - push 的 refspec(形如 `HEAD:refs/heads/`)。 * @param {string} serverUrl - Gitea 伺服器根網址(用於定位 checkout 持久化 extraheader 的 scope)。 * @returns {void} 成功即返回;失敗拋出不含 URL/argv/token 的固定錯誤。 * @throws {Error} 推送失敗時拋出固定訊息(已隱藏遠端 URL 與認證資訊)。 * @remarks 本函式未匯出,僅供 {@link commitAndPushFindings} 使用。 */ -function pushWithCredential(cwd, remoteUrl, secret, refspec, serverUrl) { - const basic = Buffer.from(`ai-review-bot:${secret}`).toString('base64'); +function pushWithCredential(cwd, remoteUrl, token, refspec, serverUrl) { + const server = new URL(serverUrl); + const remote = new URL(remoteUrl); + if (remote.origin !== server.origin || !remote.pathname.endsWith('.git')) { + throw new Error('推送遠端 URL 與 Gitea 伺服器不相符,已停止推送。'); + } + const basic = Buffer.from(`ai-review-bot:${token}`).toString('base64'); // checkout 持久化自動 token 的 scope 為 `http./.extraheader`(結尾帶斜線)。 - const headerScope = `http.${serverUrl.replace(/\/+$/, '')}/.extraheader`; + const headerScope = `http.${server.origin}/.extraheader`; try { execFileSync('git', ['push', remoteUrl, refspec], { cwd, @@ -333,4 +368,7 @@ module.exports = { fileDiff, fileLastUpdatedIso, commitAndPushFindings, + __test: { + assertSafeBranchRef, + }, }; diff --git a/src/lib/review.js b/src/lib/review.js index cbb2c7a..a326caa 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -36,7 +36,7 @@ const AGENT_DIAGNOSTIC_OUTPUT_LIMIT = 500; function redactSecrets(text) { return String(text ?? '') .replace(/[\r\n\t\v\f\x00-\x1f\x7f]+/g, ' ') - .replace(/(authorization\s*[:=]\s*)\S+/gi, '$1***') + .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, '$1***') .replace(/((?:api[_-]?key|token|password|secret|bearer)\s*[:=]\s*)\S+/gi, '$1***') .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/gi, '$1***:***@') .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, '***') @@ -48,10 +48,9 @@ function redactSecrets(text) { * 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。 * * 安全考量:AI CLI 失敗時可能在 stderr/stdout 回顯提示內容、環境資訊、token、PII 或 - * 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。權衡「可除錯性」後:本函式 - * 於失敗時**預設**附上經 {@link redactSecrets} 遮罩且去除控制字元的 **stderr 與 stdout** - * 片段(各先截去過長輸入再取前 500 字)——只印 exit code 幾乎無從判斷 CLI 為何失敗, - * 且部分 CLI(如 claude-code 的 `-p` 模式)將錯誤寫到 stdout 而非 stderr。純函式、不拋例外。 + * 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。因此本函式預設只輸出退出碼、 + * 訊號與逾時狀態;只有 `ACTIONS_STEP_DEBUG=true` 時才附上經 {@link redactSecrets} + * 遮罩且去除控制字元的 stderr/stdout 片段(各先截去過長輸入再取前 500 字)。 * * @param {{error: (Error & {code?: number|string, signal?: string, killed?: boolean})|null, stderr?: string, output?: string}} res * `runAgent` 的回傳物件。 @@ -59,7 +58,7 @@ function redactSecrets(text) { * @remarks * 使用情境:{@link runAttackers}/{@link runDefenders}/{@link fillPurposes}/{@link selectLabels} * 判定 `!res.ok` 時,以本函式把失敗細節寫進 WRN log,讓 CI 記錄能看出 AI CLI 為何失敗; - * 需要原始輸出診斷時,於 workflow 設定 secret `ACTIONS_STEP_DEBUG=true` 再重跑。 + * 需要輸出片段輔助診斷時,於 workflow 設定 `ACTIONS_STEP_DEBUG=true` 再重跑。 * 本函式未匯出,僅供模組內部使用。 */ function agentFailureDetail(res) { @@ -71,14 +70,13 @@ function agentFailureDetail(res) { else if (err.code) parts.push(`code ${err.code}`); else if (err.signal) parts.push(`signal ${err.signal}`); } - // 先截去過長輸入(INPUT_LIMIT)再遮罩,最終仍截為 AGENT_DIAGNOSTIC_OUTPUT_LIMIT;長度政策常數見模組頂層。 - // 預設即附上「經 redactSecrets 遮罩+去控制字元+限長」的 stderr 與 stdout 片段——CLI 失敗時 - // 只印 exit code 幾乎無從除錯(見 test-claude 秒失敗案例);且部分 CLI(如 claude-code 的 - // -p 模式)會把錯誤寫到 stdout 而非 stderr,故兩者都輸出。redactSecrets 為盡力防線。 - const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT)); - if (stderr) parts.push(`stderr:${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); - const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT)); - if (stdout) parts.push(`stdout:${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); + // 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段。 + if (process.env.ACTIONS_STEP_DEBUG === 'true') { + const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT)); + if (stderr) parts.push(`stderr:${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); + const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT)); + if (stdout) parts.push(`stdout:${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); + } if (parts.length === 0) { parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)'); } @@ -739,9 +737,9 @@ ${JSON.stringify(brief)} * 不使用 {@link templates.othersComment} 的單一表格——表格僅用於一般模式(PR)。 */ async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) { - for (const finding of severe) { - await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding)); - } + await Promise.all( + severe.map((finding) => gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding))), + ); log('步驟9', 'INF', `已將 ${severe.length} 條嚴重問題留言到 issue #${issueNumber}。`); } @@ -765,9 +763,9 @@ async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) { * 以本函式把警告+建議逐條留言到追蹤 issue,確保 issue 上每條問題都是可個別回覆的留言。 */ async function postOthersToIssue({ ctx, gitea, issueNumber, others }) { - for (const finding of others) { - await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding)); - } + await Promise.all( + others.map((finding) => gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding))), + ); log('步驟10', 'INF', `已將 ${others.length} 條警告+建議逐條留言到 issue #${issueNumber}。`); } @@ -933,4 +931,8 @@ module.exports = { postOthersToIssue, resolveOldComments, postSevereComments, + __test: { + agentFailureDetail, + redactSecrets, + }, }; diff --git a/src/lib/templates.js b/src/lib/templates.js index f02e30b..c970629 100644 --- a/src/lib/templates.js +++ b/src/lib/templates.js @@ -334,9 +334,9 @@ ${body || '(PR 無描述)'} * @param {string} [finding.suggestedCode] - 建議寫法程式碼;有值才輸出「建議寫法」區塊。 * @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。 * @remarks - * 使用情境:建問題模式下 `review.postSevereToIssue`(src/lib/review.js)把每條嚴重 finding - * 以本函式產生留言內容、經 `gitea.createCommentOnIssue` 發布到追蹤 issue 上, - * 作為問題明細的追蹤紀錄。 + * 使用情境:建問題模式下 `review.postSevereToIssue` 與 `review.postOthersToIssue` + * 把每條 finding 以本函式產生留言內容、經 `gitea.createCommentOnIssue` + * 發布到追蹤 issue 上,作為問題明細的追蹤紀錄。 */ function issueFindingComment(finding) { const emoji = SEVERITY_EMOJI[finding.severity] || '🔵';