科技 · 實用指南
Git 顯示所有權可疑?修改 safe.directory 前先稽核儲存庫
在修改權限或加入 safe.directory 例外前,先為 Git 的所有權警告建立唯讀證據清單。

Git 的「detected dubious ownership」訊息是一道安全攔截,不代表儲存庫已損壞。自 Git 2.35.2 起,命令可拒絕由另一個身分擁有的儲存庫。官方 safe.directory 設定允許例外,但 Git 只從受保護設定讀取它,因此不受信任的儲存庫無法替自己授權。
不要從 safe.directory=* 開始。這個值會對該 Git 安裝可見的所有儲存庫關閉所有權檢查。請先記錄確切的 Git 執行檔、規範路徑、目前帳號、檔案系統擁有者、掛載環境與既有受保護 safe.directory 值,再選擇範圍最小且有依據的變更。
下方稽核只會在儲存庫外寫入一份 JSON 報告。它不會在目標內執行 Git、不會解析儲存庫設定、不會修改所有權或權限、不會加入信任項目,也不會簽出檔案、執行掛鉤或連線到遠端。
1. 原樣保留警告與路徑
複製完整錯誤,包括 Git 顯示的路徑以及擁有者和目前使用者身分。也要記錄是哪個程式觸發錯誤:終端機、IDE、圖形用戶端、CI 執行器、Git LFS 程序、容器或服務可能使用不同的 Git 執行檔與帳號。
不要憑印象統一路徑。Windows Git 與 WSL 可能用不同路徑語法和受保護設定存取同一批檔案。符號連結、連結工作樹、掛載磁碟與網路共享也可能讓輸入路徑不同於 Git 實際檢查的物件。
2. 了解 safe.directory 真正改變什麼
Git 將 safe.directory 定義為可含多個值的目錄清單,使 Git 即使面對由其他使用者擁有的儲存庫也能使用它。此設定只在受保護設定中生效。空值可重設繼承項目;以 /* 結尾的路徑會信任其下儲存庫;* 會完全關閉所有權檢查。
如果私人副本意外屬於舊 SID、root 或其他帳號,修正所有權可能正確。如果儲存庫原本就是共享,在確認路徑與擁有者後加入一個精確 safe.directory 項目可能合理。萬用字元會掩蓋兩種情況。
3. 從儲存庫外執行唯讀稽核
將下方程式儲存為 audit_git_trust.mjs,放在中立工作資料夾。第一個參數是儲存庫路徑,第二個可選參數是在儲存庫外新建的報告檔。程式拒絕覆寫報告,也拒絕把報告寫入目標。
Windows PowerShell:node .\audit_git_trust.mjs "D:\Work\project" ".\git-trust-project.json"。Linux 或 macOS:node ./audit_git_trust.mjs "/mnt/work/project" "./git-trust-project.json"。七項合成測試使用 Node 24.19.0;其他版本不在此次驗證範圍。
```javascript #!/usr/bin/env node import {access, lstat, mkdir, open, readFile, realpath, stat} from 'node:fs/promises'; import {constants as fsConstants} from 'node:fs'; import {execFile} from 'node:child_process'; import {promisify} from 'node:util'; import {basename, delimiter, dirname, isAbsolute, join, resolve} from 'node:path'; import {tmpdir} from 'node:os'; const runFile = promisify(execFile); const fail = (code, detail = '') => { const error = new Error(detail ? `${code}: ${detail}` : code); error.code = code; throw error; }; const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); const normalizePrefix = value => process.platform === 'win32' ? value.toLowerCase() : value; const isWithin = (parent, child) => { const p = normalizePrefix(resolve(parent)); const c = normalizePrefix(resolve(child)); return c === p || c.startsWith(p.endsWith('/') || p.endsWith('\\') ? p : p + (process.platform === 'win32' ? '\\' : '/')); }; async function existingExecutable(name) { const extensions = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']; for (const folder of (process.env.PATH || '').split(delimiter).filter(Boolean)) { for (const extension of extensions) { const candidate = join(folder, process.platform === 'win32' ? name + extension.toLowerCase() : name); try { await access(candidate, process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK); return await realpath(candidate).catch(() => resolve(candidate)); } catch {} } } return null; } async function command(executable, args, options = {}) { try { const result = await runFile(executable, args, { cwd: options.cwd || tmpdir(), encoding: 'utf8', timeout: 15_000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, env: process.env }); return {ok: true, stdout: result.stdout, stderr: result.stderr}; } catch (error) { return { ok: false, code: typeof error.code === 'string' || typeof error.code === 'number' ? error.code : 'COMMAND_FAILED', stdout: typeof error.stdout === 'string' ? error.stdout : '', stderr: typeof error.stderr === 'string' ? error.stderr : '' }; } } async function inspectGitMarker(root) { const marker = join(root, '.git'); try { const info = await lstat(marker); if (info.isDirectory()) return {kind: 'directory', marker, adminPath: marker}; if (!info.isFile()) return {kind: 'unsupported-marker', marker}; const text = await readFile(marker, 'utf8'); const match = /^gitdir:\s*(.+?)\s*$/im.exec(text); if (!match) return {kind: 'unrecognized-file', marker}; const candidate = isAbsolute(match[1]) ? match[1] : resolve(root, match[1]); return {kind: 'gitdir-file', marker, declared: match[1], adminPath: await realpath(candidate).catch(() => resolve(candidate))}; } catch (error) { if (error.code !== 'ENOENT') throw error; } const [head, objects, refs] = await Promise.all([ stat(join(root, 'HEAD')).catch(() => null), stat(join(root, 'objects')).catch(() => null), stat(join(root, 'refs')).catch(() => null) ]); return head?.isFile() && objects?.isDirectory() && refs?.isDirectory() ? {kind: 'bare-repository', adminPath: root} : {kind: 'not-detected'}; } async function posixIdentity(targetStat) { const user = await command('id', ['-un']); const group = await command('id', ['-gn']); return { process: { uid: typeof process.getuid === 'function' ? process.getuid() : null, gid: typeof process.getgid === 'function' ? process.getgid() : null, user: user.ok ? user.stdout.trim() : null, group: group.ok ? group.stdout.trim() : null, sudoUid: process.env.SUDO_UID || null }, target: {uid: targetStat.uid, gid: targetStat.gid, mode: `0${(targetStat.mode & 0o7777).toString(8)}`} }; } async function windowsIdentity(target) { const script = "param([string]$p) $a=Get-Acl -LiteralPath $p; [pscustomobject]@{Owner=$a.Owner;Sddl=$a.Sddl}|ConvertTo-Json -Compress"; const result = await command('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script, target]); let acl = null; if (result.ok) { try { acl = JSON.parse(result.stdout); } catch { acl = {parseError: true, raw: result.stdout.trim()}; } } return { process: {user: process.env.USERNAME || null, userDomain: process.env.USERDOMAIN || null}, target: acl, aclRead: result.ok, aclError: result.ok ? null : (result.stderr.trim() || String(result.code)) }; } function decodeMountField(value) { return value.replaceAll('\\040', ' ').replaceAll('\\011', '\t').replaceAll('\\012', '\n').replaceAll('\\134', '\\'); } async function linuxMount(target) { try { const lines = (await readFile('/proc/self/mountinfo', 'utf8')).trim().split('\n'); const candidates = lines.map(line => { const parts = line.split(' '), separator = parts.indexOf('-'); if (separator < 6) return null; return { mountPoint: decodeMountField(parts[4]), mountOptions: parts[5], optionalFields: parts.slice(6, separator), filesystem: parts[separator + 1], source: decodeMountField(parts[separator + 2] || ''), superOptions: parts.slice(separator + 3).join(' ') }; }).filter(Boolean).filter(item => isWithin(item.mountPoint, target)); return candidates.sort((a, b) => b.mountPoint.length - a.mountPoint.length)[0] || null; } catch (error) { return {readError: error.code || error.message}; } } async function portableMount(target) { if (process.platform === 'linux') return linuxMount(target); if (process.platform === 'win32') { const root = /^[A-Za-z]:\\/.exec(target)?.[0]?.slice(0, 2) || null; if (!root) return {drive: null}; const script = "param([string]$d) Get-Volume -DriveLetter $d.TrimEnd(':') | Select DriveLetter,FileSystem,DriveType,HealthStatus | ConvertTo-Json -Compress"; const result = await command('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script, root]); return result.ok ? {drive: root, raw: result.stdout.trim()} : {drive: root, error: result.stderr.trim() || String(result.code)}; } const result = await command('df', ['-P', target]); return result.ok ? {raw: result.stdout.trim()} : {error: result.stderr.trim() || String(result.code)}; } async function protectedConfig(git) { if (!git) return {available: false, reason: 'git-not-found-in-path'}; const version = await command(git, ['--version']); const traced = await command(git, ['config', '--show-origin', '--show-scope', '--get-all', 'safe.directory']); const global = await command(git, ['config', '--global', '--get-all', 'safe.directory']); const system = await command(git, ['config', '--system', '--get-all', 'safe.directory']); return { available: version.ok, executable: git, version: version.stdout.trim() || null, safeDirectoryWithOrigin: traced.stdout.split(/\r?\n/).filter(Boolean), globalValues: global.stdout.split(/\r?\n/).filter(Boolean), systemValues: system.stdout.split(/\r?\n/).filter(Boolean), notes: [ !traced.ok && traced.code !== 1 ? `origin-read-failed:${traced.code}` : null, !global.ok && global.code !== 1 ? `global-read-failed:${global.code}` : null, !system.ok && system.code !== 1 ? `system-read-failed:${system.code}` : null ].filter(Boolean) }; } async function main() { if (process.argv.length < 3 || process.argv.length > 4) fail('USAGE', 'node audit_git_trust.mjs REPOSITORY_PATH [REPORT.json]'); const requestedPath = resolve(process.argv[2]); const output = resolve(process.argv[3] || `git-trust-audit-${Date.now()}.json`); const target = await realpath(requestedPath).catch(error => fail('TARGET_NOT_FOUND', error.code || error.message)); const targetStat = await stat(target); if (!targetStat.isDirectory()) fail('TARGET_NOT_DIRECTORY'); if (isWithin(target, output)) fail('OUTPUT_INSIDE_TARGET', 'write the report outside the repository being audited'); const git = await existingExecutable('git'); const report = { schemaVersion: 1, createdAt: new Date().toISOString(), readOnlyScope: { targetMutation: false, commandsExecutedInsideTarget: false, configWrites: false, ownershipOrPermissionWrites: false, repositoryOperations: false }, paths: {requested: requestedPath, canonical: target, targetName: basename(target), output}, marker: await inspectGitMarker(target), identity: process.platform === 'win32' ? await windowsIdentity(target) : await posixIdentity(targetStat), mount: await portableMount(target), git: await protectedConfig(git), interpretation: { warningMeaning: 'Git declined to trust a repository whose owner differs from the current execution identity; this is not proof of repository corruption.', minimumChangeRule: 'Verify path, identity and ownership first. Prefer correcting unintended ownership. For an intentionally shared repository, review one exact protected safe.directory entry instead of a wildcard.', limits: 'This ledger does not decide whether an owner or path is trustworthy and does not modify the repository, filesystem ownership, permissions or Git configuration.' } }; if (!isObject(report.marker) || !isObject(report.identity) || !isObject(report.git)) fail('REPORT_BUILD_FAILED'); await mkdir(dirname(output), {recursive: true}); const handle = await open(output, 'wx', 0o600).catch(error => fail(error.code === 'EEXIST' ? 'OUTPUT_EXISTS' : 'OUTPUT_OPEN_FAILED', error.code)); try { await handle.writeFile(JSON.stringify(report, null, 2) + '\n', 'utf8'); await handle.sync(); } finally { await handle.close(); } console.log(`Wrote read-only Git trust audit: ${output}`); } main().catch(error => { console.error(error.code || error.message || 'UNEXPECTED_ERROR'); process.exitCode = 1; }); ```
4. 閱讀報告,但不要把它視為安全判決
paths 同時保留要求的路徑與規範檔案系統路徑。marker 區分一般 .git 目錄、連結工作樹的 gitdir: 檔、裸儲存庫以及未偵測到標記的路徑,避免 Git 實際檢查另一位置時誤信任別名。
identity 比較執行帳號與所有權證據。POSIX 記錄 UID/GID 與使用者/群組;Windows 以 PowerShell 唯讀取得 ACL 擁有者和 SDDL。不一致需要調查,但不表示任一方惡意。
mount 記錄最相符的 Linux 掛載點或平台磁碟/檔案系統結果。git 記錄 PATH 中的執行檔、版本,以及 Git 能提供的受保護 safe.directory 值與來源/範圍。程式不會讓 Git 開啟儲存庫本機設定。
5. 變更前先分類原因
私人副本意外屬於舊帳號、root 或安裝程式時標記為 wrong-owner。終端機、IDE、WSL、容器或服務讀取不同 Git/設定時標記為 wrong-executable-or-scope。只有確認規範路徑、擁有者安排、授權寫入者、掛鉤與設定後,才標記 intentional-shared-repository。
SMB、NFS、外接磁碟或綁定掛載呈現不同擁有者時標記 mount-or-share-identity。無法確定路徑、擁有者或執行環境時使用 unknown。
未釐清共享範圍前不要遞迴修改所有權;不要把 sudo Git 當日常繞過方式;不要把論壇的萬用字元命令直接寫入全域或系統設定。
6. 只採用已驗證且範圍最小的修正
私人副本出現非預期擁有者時,透過作業系統正常管理流程改為預期帳號。Atlassian 提供 takeown 與 chown 範例,但帳號、群組、繼承及共享儲存政策仍由環境決定。遞迴變更前先保存重要工作。
對刻意共享的儲存庫,只在受影響 Git 程序讀取的受保護設定中加入規範路徑。若另一個程式或帳號報錯,重新稽核。報告刻意不產生或執行寫入命令。
WSL、容器、CI 與服務帳號要修正實際執行 Git 的環境。Windows Git 設定不會設定 WSL Git;開發者全域檔不會設定服務帳號;主機設定也未必進入容器。GitLab 文件顯示錯誤執行帳號可能就是原因。
7. 驗證結果並保存回復證據
管理員核准變更後,用新檔名再次執行相同稽核,比較執行檔、規範路徑、身分、擁有者、掛載與 safe.directory 來源。保留變更前後報告與原始錯誤。
在原先失敗的同一程式或帳號下執行工作流程所需的最小唯讀 Git 命令,例如 git status --short。另一個 shell 成功不代表 IDE、服務或共享程序已修好。
若精確例外隱藏警告但路徑或擁有者仍無法解釋,請從相同受保護範圍移除例外並繼續調查。警告消失只證明信任閘門已被滿足。
8. 知道稽核的界線
程式不會驗證物件、提交、掛鉤或遠端身分,不會證明共享儲存庫安全,也不能復原已刪除工作。Windows 需要 PowerShell 與 ACL 報告;掛載細節依賴 /proc/self/mountinfo、PowerShell 或 df。
七項合成測試涵蓋一般工作樹、gitdir: 檔、裸儲存庫、符號連結目標、遺失目標、拒絕覆寫及拒絕寫入受稽核目錄。目標檔案雜湊未改變。測試使用 Linux 與 Node 24.19.0,未涵蓋真實 Windows SID、SMB/NFS、WSL、容器、IDE 或 CI 帳號。
