diff --git a/src/lib/review.js b/src/lib/review.js index e197a77..1d1b47a 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -14,18 +14,44 @@ const PER_FILE_DIFF_LIMIT = 16_000; const TOTAL_DIFF_LIMIT = 160_000; /** - * 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號/stderr/stdout 片段。 + * 遮罩單行診斷文字中的機密與控制字元,避免寫進 CI log 時外洩。 * - * AI CLI 失敗時常見 stderr 為空、真正原因印在 stdout(例如 CLI 用法錯誤、未認證或額度提示), - * 若只記 `error.message` 會看不出原因。本函式把 exit code、stderr、stdout 各截前 500 字併成一行, - * 供各失敗點的 WRN log 使用。純函式、不拋例外。 + * 處理順序:換行與控制字元一律壓成單一空白(避免注入假日誌行)→ 遮蔽 + * `Authorization` 標頭、`token=`/`token:` 型憑證、URL 內嵌帳密、以及常見長金鑰/ + * 長 hex/`ghp_` 等 token 樣式。屬「盡力遮罩」——無法窮舉所有機密格式,故僅在 + * 明確開啟除錯輸出時作為第二道防線使用(見 {@link agentFailureDetail})。 + * + * @param {*} text - 待遮罩的原始文字(非字串會先以 `String()` 轉型)。 + * @returns {string} 已去控制字元並遮蔽常見機密樣式的單行文字。 + * @remarks 本函式未匯出,僅供模組內部使用。 + */ +function redactSecrets(text) { + return String(text ?? '') + .replace(/[\r\n\t\v\f\x00-\x1f\x7f]+/g, ' ') + .replace(/(authorization\s*[:=]\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, '***') + .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '***') + .trim(); +} + +/** + * 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。 + * + * 安全考量:AI CLI 失敗時可能在 stderr/stdout 回顯提示內容、環境資訊、token、PII 或 + * 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。故本函式**預設只輸出** + * exit code/signal/killed 等不含機密的分類資訊;**僅在明確開啟 Actions step debug** + * (環境變數 `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` 的回傳物件。 - * @returns {string} 單行診斷摘要(各段以「|」分隔);無任何資訊時回傳 error.message 或「未知錯誤」。 + * @returns {string} 單行診斷摘要(各段以「|」分隔);無任何資訊時回傳固定字串。 * @remarks * 使用情境:{@link runAttackers}/{@link runDefenders}/{@link fillPurposes}/{@link selectLabels} - * 判定 `!res.ok` 時,以本函式把失敗細節寫進 WRN log,讓 CI 記錄能看出 AI CLI 為何失敗。 + * 判定 `!res.ok` 時,以本函式把失敗細節寫進 WRN log,讓 CI 記錄能看出 AI CLI 為何失敗; + * 需要原始輸出診斷時,於 workflow 設定 secret `ACTIONS_STEP_DEBUG=true` 再重跑。 * 本函式未匯出,僅供模組內部使用。 */ function agentFailureDetail(res) { @@ -37,11 +63,18 @@ function agentFailureDetail(res) { else if (err.code) parts.push(`code ${err.code}`); else if (err.signal) parts.push(`signal ${err.signal}`); } - const stderr = String((res && res.stderr) || '').trim(); - if (stderr) parts.push(`stderr:${stderr.slice(0, 500)}`); - const stdout = String((res && res.output) || '').trim(); - if (stdout) parts.push(`stdout:${stdout.slice(0, 500)}`); - if (parts.length === 0) parts.push((err && err.message) || '未知錯誤'); + // 預設不輸出 AI CLI 原始 stderr/stdout(可能含 token、PII 或原始碼祕密); + // 僅在明確開啟 Actions step debug 時,附上「已遮罩+去控制字元」的片段作為診斷。 + const verbose = String(process.env.ACTIONS_STEP_DEBUG || '').trim().toLowerCase() === 'true'; + if (verbose) { + const stderr = redactSecrets((res && res.stderr) || ''); + if (stderr) parts.push(`stderr:${stderr.slice(0, 500)}`); + const stdout = redactSecrets((res && res.output) || ''); + if (stdout) parts.push(`stdout:${stdout.slice(0, 500)}`); + } + if (parts.length === 0) { + parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(詳細輸出已隱藏)'); + } return parts.join('|'); } @@ -629,8 +662,8 @@ function sortFindings(findings) { * @returns {Promise} 挑中的標籤 id 陣列(可用標籤的子集合);無適合標籤或任何失敗時為空陣列。 * @remarks * 使用情境:建問題模式(input: create-issue)下,`main()`(src/index.js) - * 在建立追蹤 issue 後先呼叫 `gitea.listLabels` 取得可用標籤,再以本函式依保留問題 - * 挑出標籤 id 子集合,交給 `gitea.addLabelsToIssue` 補掛到 issue 上。 + * 於確定有保留問題後、建立追蹤 issue 前先呼叫 `gitea.listLabels` 取得可用標籤, + * 再以本函式依保留問題挑出標籤 id 子集合,於 `gitea.createIssue` 建立 issue 時一次帶入。 */ async function selectLabels({ tool, model, cwd, labels, prTitle, prBody, findings }) { if (labels.length === 0) return []; @@ -704,6 +737,32 @@ async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) { log('步驟9', 'INF', `已將 ${severe.length} 條嚴重問題留言到 issue #${issueNumber}。`); } +/** + * 建問題模式:把警告+建議(非嚴重)findings 逐條以獨立留言發到追蹤 issue。 + * + * 與一般模式(PR)把警告+建議彙整成單一表格({@link templates.othersComment})不同: + * issue 內每條問題各發一則留言({@link templates.issueFindingComment},內文標明位置), + * 讓開發者能針對「單一問題」直接回覆討論,而非只能回覆一整張表格。 + * findings 由呼叫端事先以 {@link sortFindings} 排序(嚴重度→檔案→行號),本函式不再排序。 + * + * @param {Object} params - 解構參數。 + * @param {Object} params.ctx - 執行環境 context(`loadContext()` 回傳);供 Gitea API 認證。 + * @param {Object} params.gitea - Gitea API 模組(src/lib/gitea.js);以參數注入便於測試替換,使用 `createCommentOnIssue`。 + * @param {number} params.issueNumber - 目標追蹤 issue 的編號。 + * @param {Array} params.others - severity 非「嚴重」(警告+建議)的 finding 列表(已排序;呼叫端保證非空)。 + * @returns {Promise} 無回傳值;結果反映在 issue 留言與日誌。 + * @throws {Error} 逐條留言(`createCommentOnIssue`)失敗時未攔截、向上拋出,由主流程頂層 catch 收斂。 + * @remarks + * 使用情境:建問題模式(input: create-issue)下,`main()`(src/index.js)步驟 10 + * 以本函式把警告+建議逐條留言到追蹤 issue,確保 issue 上每條問題都是可個別回覆的留言。 + */ +async function postOthersToIssue({ ctx, gitea, issueNumber, others }) { + for (const finding of others) { + await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding)); + } + log('步驟10', 'INF', `已將 ${others.length} 條警告+建議逐條留言到 issue #${issueNumber}。`); +} + /** * 讀取 finding 對應的程式碼片段:新版檔案的 startLine..endLine,最多 40 行。 * @@ -746,12 +805,14 @@ function readSnippet(cwd, finding) { * @param {Object} params - 解構參數。 * @param {Object} params.ctx - 執行環境 context(`loadContext()` 產出,含 repo/PR 編號/token 等 API 呼叫所需資訊)。 * @param {Object} params.gitea - Gitea API 模組(`src/lib/gitea.js`),需提供 `whoAmI`/`listIssueComments`/`editIssueComment`/`listReviews`/`listReviewComments`/`tryResolveReviewComment`;以參數注入便於測試替換。 - * @param {Set} params.currentRunCommentIds - 本回合發出的一般留言 id 集合;這些留言不標註過時。 + * @param {Set} params.currentRunCommentIds - 本回合已發出的一般留言 id 集合;這些留言不標註過時。 * @returns {Promise} 無回傳值;結果反映在 PR 留言狀態與日誌。 * @remarks - * 使用情境:審查流程「步驟 2」在步驟 1 快速回報與前置檢查之後、偵測工具(步驟 3) - * 與所有本回合留言之前呼叫;此時本回合尚未發出任何留言(currentRunCommentIds 為空), - * 之後發出的留言自然不受影響,確保 PR 上只有最新回合的審查結果醒目可見。 + * 使用情境:一般模式下,`src/index.js` 於「確定本回合審查已成功產生結果後、發布嚴重/ + * 其他問題留言之前」呼叫(見 `main()`),刻意延後到工具偵測、diff 整理與攻防裁決都成功之後, + * 避免任一前置步驟失敗時舊結果已被清掉、PR 卻沒有新結果。此時本回合的工具/diff/角色留言 + * 已發出並登錄於 `currentRunCommentIds`,本函式據此排除、不會把這些「新產生的留言」誤標為過時; + * 之後才發布的嚴重/其他問題留言更不受影響,確保 PR 上只有最新回合的審查結果醒目可見。 */ async function resolveOldComments({ ctx, gitea, currentRunCommentIds }) { let botLogin = ''; @@ -861,6 +922,7 @@ module.exports = { appendExclusions, selectLabels, postSevereToIssue, + postOthersToIssue, resolveOldComments, postSevereComments, };