テクノロジー · 実用ガイド
Git の「dubious ownership」警告:safe.directory を変える前に監査する
権限変更や safe.directory 例外の追加より先に、Git の所有権警告を読み解く読み取り専用の証拠台帳を作ります。

Git の「detected dubious ownership」はセキュリティ上の停止であり、リポジトリ破損の証拠ではありません。Git 2.35.2 以降、別の主体が所有するリポジトリをコマンドが拒否できます。公式の safe.directory は例外を許しますが、信頼されていないリポジトリが自分を許可できないよう、保護された設定だけが有効です。
最初から safe.directory=* を使わないでください。これは、その Git から見えるすべてのリポジトリで所有権検査を無効にします。正確な Git 実行ファイル、正規化されたパス、現在のアカウント、ファイル所有者、マウント状況、既存の保護された safe.directory 値を記録し、根拠のある最小範囲の変更を選びます。
次の監査はリポジトリ外に JSON レポートを1つ作るだけです。対象内で Git を実行せず、ローカル設定を解析せず、所有権・権限・信頼設定を変更せず、チェックアウト、フック実行、リモート通信も行いません。
1. 警告とパスをそのまま保存する
Git が示したパス、所有者、現在のユーザーを含むエラー全文を保存します。端末、IDE、GUI、CI、Git LFS、コンテナ、サービスのどれが出したかも記録します。別の Git 実行ファイルやアカウントが使われることがあるためです。
見た目でパスを統一しないでください。Windows Git と WSL は同じファイルに別の表記と保護設定でアクセスします。シンボリックリンク、リンクワークツリー、マウント、ネットワーク共有も、入力した場所と Git が評価した実体を分けます。
2. safe.directory が変える範囲を理解する
Git は safe.directory を、別ユーザー所有でも利用を許す複数値の一覧として定義します。保護された設定でのみ有効です。空値は継承一覧をリセットし、末尾 /* は配下を信頼し、* は検査全体を無効にします。
個人用コピーが古い SID、root、別アカウントの所有なら、所有権修正が適切かもしれません。意図した共有なら、パスと所有者を確認して1つの正確な例外を検討できます。ワイルドカードは両者の違いを隠します。
3. リポジトリ外から読み取り専用監査を実行する
下のプログラムを audit_git_trust.mjs として中立なフォルダーに保存します。第1引数は対象パス、第2引数は対象外に新規作成するレポートです。既存ファイルの上書きと対象内への出力は拒否されます。
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"。7件の合成テストは 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: ファイル、bare リポジトリ、未検出を区別し、別の実体を評価しているのに別名を信頼する事故を防ぎます。
identity は実行アカウントと所有権情報を並べます。POSIX では UID/GID とユーザー/グループ、Windows では PowerShell で ACL 所有者と SDDL を読み取ります。不一致は調査対象であり、悪意の証明ではありません。
mount は最も近い Linux マウント点または各 OS のドライブ情報を記録します。git は PATH 上の実行ファイル、版、保護された safe.directory の値と取得可能な出所/スコープを記録します。Git にリポジトリローカル設定を開かせません。
5. 変更前に原因を分類する
個人用コピーが古いアカウント、root、インストーラー所有なら wrong-owner。端末、IDE、WSL、コンテナ、サービスが別 Git/設定を読むなら wrong-executable-or-scope。正規パス、所有関係、許可された利用者、フック、設定を確認できた共有だけ intentional-shared-repository とします。
SMB、NFS、外付け媒体、bind mount が別の所有者を提示するなら mount-or-share-identity。パス、所有者、実行環境を確定できなければ unknown です。
共有範囲を理解せず再帰的に所有権を変えないでください。sudo Git を常用の回避策にせず、掲示板のワイルドカードをグローバル/システム設定へ写さないでください。
6. 検証済みの最小変更だけを適用する
個人用コピーの所有者が意図と違う場合、OS の標準管理手順で正しいアカウントへ直します。Atlassian は takeown/chown 例を示しますが、アカウント、グループ、継承、共有ストレージ方針は環境固有です。再帰変更の前に重要な作業を保全します。
意図した共有では、影響を受けた Git が読む保護設定に正規パス1つだけを追加します。別のアプリやアカウントで出るなら再監査します。レポートは書き込みコマンドを生成・実行しません。
WSL、コンテナ、CI、サービスでは実際に Git を動かす文脈を直します。Windows Git の設定は WSL Git を設定せず、開発者のグローバル設定はサービスに届かず、ホスト設定もコンテナへ入るとは限りません。GitLab 文書も誤った実行アカウントを原因として示します。
7. 結果を検証し、戻すための証拠を残す
承認された変更後、別名のレポートで再監査し、実行ファイル、正規パス、身分、所有者、マウント、safe.directory 出所を比較します。前後レポートと元のエラーを保存します。
元と同じアプリ/アカウントで、必要最小限の読み取りコマンド(例:git status --short)を試します。別のシェルで成功しても IDE、サービス、共有処理の修復を証明しません。
正確な例外で警告が消えてもパスや所有者が説明できなければ、同じ保護スコープから例外を外して調査を続けます。警告消失は信頼ゲートを通ったことしか示しません。
8. 監査の限界を知る
プログラムはオブジェクト、コミット、フック、リモート身分を検証せず、共有リポジトリの安全性を証明せず、削除データを復元しません。Windows は PowerShell/ACL、マウント情報は /proc/self/mountinfo、PowerShell、df に依存します。
7件の合成テストは通常ワークツリー、gitdir:、bare、シンボリックリンク、存在しない対象、上書き拒否、対象内書き込み拒否をカバーし、対象ハッシュは不変でした。Linux/Node 24.19.0 のみで、実際の Windows SID、SMB/NFS、WSL、コンテナ、IDE、CI は未試験です。
