技术 · 实用指南
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;其他 Node 版本不在本次验证范围。
```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。另一个终端成功并不能证明 IDE、服务或共享进程已修复。
如果精确例外让警告消失,但路径或所有者仍无法解释,应从同一受保护作用域删除该例外并继续调查。警告消失只证明信任门槛已被满足。
8. 明确审计的边界
程序不会验证对象、提交、钩子或远程身份,不会证明共享仓库安全,也不能恢复删除的工作。Windows 需要 PowerShell 和 ACL 报告;挂载细节依赖 /proc/self/mountinfo、PowerShell 或 df。
七项合成测试覆盖普通工作树、gitdir: 文件、裸仓库、符号链接目标、缺失目标、拒绝覆盖以及拒绝写入被审计目录。目标文件哈希保持不变。测试使用 Linux 与 Node 24.19.0,没有覆盖真实 Windows SID、SMB/NFS、WSL、容器、IDE 或 CI 账户。
