Identifying and Auditing Git Pushers Behind a Shared SSH Account

🌱 Seedling

Overview

A common way to host Git repositories over SSH is to use a single Unix account, usually named git, for every person who accesses the repositories:

git@example.org

Each real user authenticates with a different SSH public key, but every server-side Git process runs under the same Unix identity.

This arrangement is convenient and can simplify repository ownership. It is also increasingly relevant because Git normally refuses to operate in a repository owned by another Unix user unless that repository is explicitly declared safe through safe.directory.

The drawback is that the operating-system account no longer identifies the actual person who performed a push. Server logs may show that the git account ran git-receive-pack, but not which human user authenticated.

The usual solution is to attach a different forced command to each public key in the shared account’s .ssh/authorized_keys file. The forced command receives a stable user identity, records it, and then delegates the original Git command to git-shell.

This pattern may be described as:

Per-key SSH forced commands for authenticated Git pusher identification.

It is also a central idea behind tools such as Gitolite.


The Problem

Suppose three developers use the same SSH endpoint:

git@example.org

On the server, they all become the same Unix user:

git

A normal SSH-level record can therefore identify:

  • the shared Unix account;
  • the SSH connection time;
  • the client address;
  • the server-side command.

It cannot, by itself, provide a convenient application-level identity such as:

kostas
maria
alex

Git commit metadata does not solve this problem.

A commit contains fields such as:

  • author;
  • author email;
  • committer;
  • committer email;
  • author and committer timestamps.

These fields are supplied by the client that creates the commit. They describe the commit, not necessarily the person who later pushed it to this particular server.

For example:

  • one developer may push commits authored by several people;
  • a mirrored repository may receive commits created elsewhere;
  • a user can configure arbitrary author and committer names;
  • a force-push may replace existing history without creating a new commit.

Consequently, commit identity and push identity are different kinds of information.


The OpenSSH Mechanism

An entry in ~git/.ssh/authorized_keys may contain options before the public key.

One of these options is:

command="..."

When that key authenticates successfully, OpenSSH runs the specified command instead of directly running the command requested by the SSH client.

The command originally requested by the client remains available in:

SSH_ORIGINAL_COMMAND

For Git-over-SSH, the original command is normally similar to one of these:

git-receive-pack '/srv/git/example.git'
git-upload-pack '/srv/git/example.git'
git-upload-archive '/srv/git/example.git'

Their purposes are:

Server-side commandOperation
git-receive-packPush
git-upload-packFetch or clone
git-upload-archiveRemote archive

A wrapper can therefore:

  1. receive an identity associated with the key;
  2. inspect SSH_ORIGINAL_COMMAND;
  3. reject commands that are not permitted;
  4. export the authenticated identity for Git hooks;
  5. write an audit record;
  6. delegate the request to git-shell.

Example authorized_keys Configuration

Assume that every user has a separate SSH key.

The shared account’s file might contain:

restrict,command="/usr/local/libexec/git-ssh-command kostas" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... kostas-laptop
restrict,command="/usr/local/libexec/git-ssh-command maria"  ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... maria-workstation
restrict,command="/usr/local/libexec/git-ssh-command alex"   ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... alex-laptop

The restrict option disables facilities that are normally unnecessary and undesirable for a Git-only account, including:

  • pseudo-terminal allocation;
  • TCP forwarding;
  • X11 forwarding;
  • SSH-agent forwarding;
  • execution of the user’s SSH rc file.

On systems whose OpenSSH version does not support restrict, equivalent restrictions can be written explicitly:

command="/usr/local/libexec/git-ssh-command kostas",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty,no-user-rc ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... kostas-laptop

The identity passed to the wrapper does not have to be an email address or Unix username. It only has to be unique and stable within the audit system.


Forced-Command Wrapper

Create:

/usr/local/libexec/git-ssh-command

Example implementation:

#!/bin/sh

set -eu

authenticated_user=${1:?Missing authenticated user identity}
original_command=${SSH_ORIGINAL_COMMAND-}
ssh_connection=${SSH_CONNECTION-unknown}

case "$original_command" in
    git-receive-pack\ *)
        operation=push
        ;;
    git-upload-pack\ *)
        operation=fetch
        ;;
    git-upload-archive\ *)
        operation=archive
        ;;
    *)
        logger -t git-ssh-auth -- \
            "result=rejected user=$authenticated_user connection=$ssh_connection command=$original_command"

        printf '%s\n' "This SSH key may only be used for Git operations." >&2
        exit 1
        ;;
esac

# Make the authenticated SSH identity available to receive hooks.
export GIT_AUTHENTICATED_USER=$authenticated_user
export GIT_AUTHENTICATED_SSH_CONNECTION=$ssh_connection

# This records the attempted Git operation, including failed pushes.
logger -t git-ssh-auth -- \
    "result=accepted user=$authenticated_user operation=$operation connection=$ssh_connection command=$original_command"

# git-shell restricts execution to Git's permitted server-side commands.
exec /usr/bin/git-shell -c "$original_command"

Make it executable and ensure ordinary users cannot modify it:

sudo chown root:root /usr/local/libexec/git-ssh-command
sudo chmod 0755 /usr/local/libexec/git-ssh-command

Why Delegate to git-shell?

The wrapper should not pass arbitrary client input to a general-purpose shell.

git-shell is specifically intended as a restricted login shell for Git access. It permits the server-side commands required for push, fetch, clone, and remote archive operations.

The wrapper performs an initial allow-list check, while git-shell provides a second restriction layer.


Recording Successful Ref Updates

The wrapper can record that a push was attempted, but it does not know whether the push ultimately succeeded or which refs were updated.

Git’s server-side receive hooks provide that information.

A post-receive hook runs after successful ref updates and receives lines in this format on standard input:

<old-object-id> <new-object-id> <ref-name>

For example:

6e51f... 44a8b... refs/heads/main
00000... 91c7d... refs/tags/v1.4.0

Create the following file in each bare repository:

hooks/post-receive

Example:

#!/bin/sh

set -eu

authenticated_user=${GIT_AUTHENTICATED_USER-unknown}
ssh_connection=${GIT_AUTHENTICATED_SSH_CONNECTION-unknown}
repository=${GIT_DIR-$(pwd)}

while read -r old_object new_object ref_name
do
    logger -t git-push-audit -- \
        "user=$authenticated_user repository=$repository ref=$ref_name old=$old_object new=$new_object connection=$ssh_connection"
done

Make it executable:

chmod 0755 hooks/post-receive

This produces one audit record for each successfully updated ref.

A log entry may resemble:

git-push-audit: user=kostas repository=/srv/git/example.git ref=refs/heads/main old=6e51f... new=44a8b... connection=203.0.113.20 53144 192.0.2.10 22

The exact destination of logger messages depends on the system’s logging configuration, such as syslog, rsyslog, or systemd-journald.


Attempted Pushes Versus Successful Pushes

There are two useful audit levels.

Wrapper log

The forced-command wrapper runs as soon as the SSH key has authenticated and a Git command has been requested.

It can record:

  • authenticated key identity;
  • connection information;
  • requested repository;
  • requested operation;
  • rejected commands;
  • push attempts that later fail.

post-receive log

The post-receive hook runs only when at least one ref update succeeds.

It can record:

  • authenticated key identity;
  • repository;
  • updated branch or tag;
  • old object ID;
  • new object ID;
  • successful creation, update, or deletion of a ref.

For a useful audit trail, both levels can be retained.


Centralizing the Hook

Copying a hook manually into every repository is error-prone.

Possible approaches include:

  1. creating repositories from a template containing the hook;
  2. using a shared hook program called by a small per-repository hook;
  3. configuring a common hooks directory where appropriate;
  4. managing repositories through a system such as Gitolite;
  5. provisioning hooks through configuration-management software.

A small delegating post-receive hook might be:

#!/bin/sh

exec /usr/local/libexec/git-post-receive-audit

The standard input from git-receive-pack is inherited by the central program.


Security and Reliability Considerations

Use one key per person or device

The audit identity is derived from the key that authenticated.

If several people share the same private key, the server cannot distinguish them. Separate keys also permit individual revocation.

Protect authorized_keys and wrapper scripts

The shared git account must not be able to replace the trusted wrapper with arbitrary code.

At minimum:

  • the wrapper should be owned by root;
  • it should not be writable by the git account;
  • .ssh and authorized_keys should have restrictive permissions;
  • repository hooks should not be modifiable by untrusted repository users.

Treat the identity as key attribution

The log proves that a particular SSH credential was used. It does not provide absolute proof that a particular human physically performed the action.

A private key may be:

  • copied;
  • shared;
  • stolen;
  • used through an agent forwarded from another machine.

The audit statement is therefore more precisely:

The push was authenticated with the SSH key assigned to this identity.

Preserve the environment

The example passes the identity to Git hooks through environment variables.

If the wrapper introduces sudo, containers, privilege changes, or environment sanitization, those variables may be removed. Their propagation must be tested in the actual deployment.

Prefer protected, append-only logging

A log stored in a file writable by the shared git account can be altered by that account.

For stronger auditing, send records to:

  • the system journal;
  • a remote syslog server;
  • an append-only audit service;
  • another process running under a separate account.

Quote and validate inputs

SSH_ORIGINAL_COMMAND originates with the client and must be treated as untrusted input.

The wrapper should:

  • accept only known Git server commands;
  • reject interactive shells and unrelated commands;
  • delegate to git-shell, not to a general-purpose shell;
  • avoid constructing commands with eval.

Log ref changes, not only commits

A push can:

  • create a branch;
  • update a branch;
  • delete a branch;
  • create or delete a tag;
  • force a ref backwards;
  • push commits authored by other people.

Recording old object ID, new object ID, and ref name accurately represents what the server accepted.


What This Mechanism Does Not Do

This arrangement does not change Git commit objects and does not insert the pusher’s name into commit history.

It creates a separate server-side audit trail.

It also does not replace:

  • signed commits;
  • signed tags;
  • signed pushes;
  • branch-protection policy;
  • authorization rules;
  • repository backups;
  • SSH-key lifecycle management.

These mechanisms answer different questions.

MechanismPrimary question answered
Commit author metadataWho does the commit claim authored it?
Committer metadataWho does the commit claim created this commit object?
Signed commit or tagWhich cryptographic key signed this object?
SSH forced-command identityWhich SSH key authenticated for this server operation?
Receive-hook audit logWhich refs did the server accept from that authenticated session?

Relationship to Gitolite

Gitolite uses the same broad architecture:

  • users connect through a shared Unix account, commonly git;
  • each user has a distinct SSH key;
  • the corresponding authorized_keys entry invokes a forced command;
  • the forced command maps the key to a Gitolite identity;
  • Gitolite applies repository and branch-level authorization before delegating to Git.

For a small installation, a custom wrapper and receive hook may be sufficient.

For a larger installation requiring centralized access-control rules, key management, groups, and repository-level permissions, Gitolite is usually preferable to maintaining a custom implementation.


Summary

When many real users access Git repositories through one shared SSH account, Unix process ownership cannot identify the real pusher.

The practical solution is:

  1. assign a separate SSH key to every user;
  2. add a per-key forced command in .ssh/authorized_keys;
  3. pass a stable identity from that entry to a restricted wrapper;
  4. inspect and validate SSH_ORIGINAL_COMMAND;
  5. delegate permitted operations to git-shell;
  6. export the authenticated identity to Git receive hooks;
  7. record successful ref updates in post-receive;
  8. store the audit records somewhere the shared account cannot alter.

This preserves the operational advantages of a shared Git account while retaining a useful record of which authenticated SSH identity performed each push.


References

Topics: Git

🌱 All notes in the Digital Garden →