Tecnología · GUÍA PRÁCTICA
¿Git detecta una propiedad dudosa? Audita antes de cambiar safe.directory
Crea un registro de pruebas de solo lectura para la alerta de propiedad de Git antes de cambiar permisos o añadir una excepción.

El mensaje “detected dubious ownership” de Git es un bloqueo de seguridad, no una prueba de corrupción. Desde Git 2.35.2, un comando puede rechazar un repositorio propiedad de otra identidad. safe.directory permite excepciones, pero Git solo las acepta desde configuración protegida para que un repositorio no fiable no pueda autorizarse a sí mismo.
No empieces con safe.directory=*. Ese valor desactiva la comprobación para todos los repositorios visibles por esa instalación de Git. Primero registra el ejecutable exacto, la ruta canónica, la cuenta actual, el propietario del sistema de archivos, el montaje y los valores protegidos existentes; después elige el cambio justificado de menor alcance.
La auditoría siguiente solo crea un informe JSON fuera del repositorio. No ejecuta Git dentro del objetivo, no lee su configuración local, no cambia propiedad, permisos ni confianza, y no hace checkout, ejecuta hooks ni contacta remotos.
1. Conserva la alerta y la ruta exactas
Copia el error completo, incluida la ruta y las identidades de propietario y usuario que muestre Git. Anota también qué aplicación lo produjo: terminal, IDE, cliente gráfico, CI, Git LFS, contenedor o servicio pueden usar otro ejecutable y otra cuenta.
No normalices la ruta a simple vista. Git para Windows y WSL pueden acceder a los mismos archivos con sintaxis y configuración protegida distintas. Enlaces, worktrees enlazados, montajes y recursos de red también separan la ruta escrita del objeto que Git evaluó.
2. Entiende qué cambia safe.directory
Git define safe.directory como una lista multivalor de repositorios permitidos aunque otro usuario sea su propietario. Solo funciona en configuración protegida. Un valor vacío restablece entradas heredadas; una ruta terminada en /* confía en sus repositorios; * desactiva toda la comprobación.
Si una copia privada pertenece por error a un SID antiguo, root u otra cuenta, corregir la propiedad puede ser adecuado. Si el repositorio se comparte de forma deliberada, una excepción exacta puede ser razonable tras verificar ruta y propietario. El comodín oculta la diferencia.
3. Ejecuta la auditoría fuera del repositorio
Guarda el programa como audit_git_trust.mjs en una carpeta neutral. El primer argumento es el repositorio y el segundo, opcional, un informe nuevo fuera de él. Rechaza sobrescribir informes y escribir dentro del objetivo.
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". Las siete pruebas sintéticas usaron Node 24.19.0; no se verificaron otras versiones.
```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. Lee el informe como evidencia, no como veredicto
paths conserva la ruta solicitada y la ruta canónica. marker distingue un .git normal, un archivo gitdir: de worktree, un repositorio bare y una ruta sin marcador, evitando confiar en un alias cuando Git evalúa otro lugar.
identity compara la cuenta ejecutora con la propiedad. En POSIX registra UID/GID y usuario/grupo; en Windows consulta con PowerShell el propietario ACL y SDDL sin cambiarlos. Una diferencia exige investigación, pero no demuestra malicia.
mount registra el montaje Linux más cercano o el resultado de unidad/sistema de archivos. git registra ejecutable, versión y valores protegidos safe.directory con origen/ámbito cuando están disponibles. Git no abre la configuración local del repositorio.
5. Clasifica la causa antes de cambiar nada
Usa wrong-owner si una copia privada pertenece inesperadamente a una cuenta antigua, root o un instalador. Usa wrong-executable-or-scope si terminal, IDE, WSL, contenedor o servicio leen otro Git/configuración. Solo usa intentional-shared-repository tras verificar ruta, propiedad, escritores autorizados, hooks y configuración.
Usa mount-or-share-identity para SMB, NFS, unidades extraíbles y bind mounts que presentan otro propietario. Usa unknown si no puedes establecer ruta, propietario o contexto.
No cambies propiedad de forma recursiva sin entender qué comparte el árbol. No uses sudo Git como atajo habitual. No copies un comodín de un foro a la configuración global o de sistema.
6. Aplica solo el remedio verificado de menor alcance
Para una copia privada con propietario equivocado, usa el procedimiento administrativo normal del sistema. Atlassian muestra ejemplos con takeown y chown, pero cuenta, grupo, herencia y política de almacenamiento son decisiones locales. Conserva el trabajo antes de un cambio recursivo.
Para un repositorio compartido deliberadamente, añade solo la ruta canónica a la configuración protegida que lee el Git afectado. Si falla otra aplicación o cuenta, vuelve a auditar. El informe no genera ni ejecuta el comando de escritura.
En WSL, contenedores, CI y servicios, corrige el contexto que ejecuta Git. La configuración de Git para Windows no configura WSL; el global de un desarrollador no configura un servicio; el ajuste del host puede no entrar al contenedor. GitLab muestra que ejecutar con la cuenta incorrecta puede ser la causa.
7. Verifica el resultado y conserva evidencia de reversión
Tras un cambio aprobado, repite la auditoría con otro nombre y compara ejecutable, ruta canónica, identidad, propietario, montaje y origen de safe.directory. Conserva informes anterior/posterior y el error original.
Ejecuta el comando Git de solo lectura mínimo, por ejemplo git status --short, desde la misma aplicación o cuenta que falló. Que funcione en otra terminal no demuestra que se arregló un IDE, servicio o proceso de red.
Si una excepción exacta oculta la alerta pero ruta o propietario siguen sin explicación, elimínala desde el mismo ámbito protegido y continúa investigando. La desaparición solo prueba que se superó la barrera de confianza.
8. Conoce los límites
El programa no valida objetos, commits, hooks ni remotos, no demuestra que un repositorio compartido sea seguro y no recupera trabajo borrado. Windows necesita PowerShell/ACL; el detalle de montaje depende de /proc/self/mountinfo, PowerShell o df.
Siete pruebas sintéticas cubrieron worktree normal, archivo gitdir:, bare, enlace simbólico, objetivo ausente, rechazo de sobrescritura y rechazo de salida interna. Los hashes del objetivo no cambiaron. Solo se probó Linux/Node 24.19.0, no SID de Windows real, SMB/NFS, WSL, contenedor, IDE o CI.
