diff --git a/go.mod b/go.mod
index 3c35132910093..b02199391ad55 100644
--- a/go.mod
+++ b/go.mod
@@ -151,6 +151,7 @@ require (
github.com/keys-pub/go-libfido2 v1.5.3-0.20220306005615-8ab03fb1ec27 // replaced
github.com/lib/pq v1.10.9
github.com/mailgun/mailgun-go/v4 v4.21.0
+ github.com/mattn/go-shellwords v1.0.12
github.com/mattn/go-sqlite3 v1.14.24
github.com/mdlayher/netlink v1.7.2
github.com/microsoft/go-mssqldb v1.8.0 // replaced
diff --git a/go.sum b/go.sum
index 5665c4f7280c7..ba347f1e9a4b6 100644
--- a/go.sum
+++ b/go.sum
@@ -1830,6 +1830,8 @@ github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
+github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
diff --git a/lib/srv/git/audit.go b/lib/srv/git/audit.go
new file mode 100644
index 0000000000000..32fc3d8b1e54c
--- /dev/null
+++ b/lib/srv/git/audit.go
@@ -0,0 +1,157 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log/slog"
+ "sync"
+
+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
+ "github.com/go-git/go-git/v5/plumbing/protocol/packp"
+ "github.com/go-git/go-git/v5/plumbing/transport"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ apievents "github.com/gravitational/teleport/api/types/events"
+ "github.com/gravitational/teleport/lib/utils/log"
+)
+
+// CommandRecorder records Git commands by implementing io.Writer to receive a
+// copy of stdin from the git client.
+type CommandRecorder interface {
+ // Writer is the basic interface for the recorder to receive payload.
+ io.Writer
+
+ // GetCommand returns basic info of the command.
+ GetCommand() Command
+ // GetActions returns the action details of the command.
+ GetActions() ([]*apievents.GitCommandAction, error)
+}
+
+// NewCommandRecorder returns a new Git command recorder.
+//
+// The recorder receives stdin input from the git client which is in Git pack
+// protocol format:
+// https://git-scm.com/docs/pack-protocol#_ssh_transport
+//
+// For SSH transport, the command type and the repository come from the command
+// executed through the SSH session.
+//
+// Based on the command type, we can decode the pack protocol defined above. In
+// general, some metadata are exchanged in pkt-lines followed by packfile sent
+// via side-bands.
+func NewCommandRecorder(parentCtx context.Context, command Command) CommandRecorder {
+ // For now, only record details on the push. Fetch is not very interesting.
+ if command.Service == transport.ReceivePackServiceName {
+ return newPushCommandRecorder(parentCtx, command)
+ }
+ return newNoopRecorder(command)
+}
+
+// noopRecorder is a no-op recorder that implements CommandRecorder
+type noopRecorder struct {
+ Command
+}
+
+func newNoopRecorder(command Command) *noopRecorder {
+ return &noopRecorder{
+ Command: command,
+ }
+}
+
+func (r *noopRecorder) GetCommand() Command {
+ return r.Command
+}
+func (r *noopRecorder) GetActions() ([]*apievents.GitCommandAction, error) {
+ return nil, nil
+}
+func (r *noopRecorder) Write(p []byte) (int, error) {
+ return len(p), nil
+}
+
+// pushCommandRecorder records actions for git-receive-pack.
+type pushCommandRecorder struct {
+ Command
+
+ parentCtx context.Context
+ logger *slog.Logger
+ payload []byte
+ mu sync.Mutex
+ seenFlush bool
+}
+
+func newPushCommandRecorder(parentCtx context.Context, command Command) *pushCommandRecorder {
+ return &pushCommandRecorder{
+ Command: command,
+ logger: slog.With(teleport.ComponentKey, "git:packp"),
+ }
+}
+
+func (r *pushCommandRecorder) GetCommand() Command {
+ return r.Command
+}
+
+func (r *pushCommandRecorder) Write(p []byte) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ // Avoid caching packfile as it can be large. Look for flush-pkt which
+ // comes after the command-list.
+ //
+ // https://git-scm.com/docs/pack-protocol#_reference_update_request_and_packfile_transfer
+ if r.seenFlush {
+ r.logger.Log(r.parentCtx, log.TraceLevel, "Discarding packet protocol", "packet_length", len(p))
+ return len(p), nil
+ }
+
+ r.logger.Log(r.parentCtx, log.TraceLevel, "Recording Git command in packet protocol", "packet", string(p))
+ r.payload = append(r.payload, p...)
+ if bytes.HasSuffix(p, pktline.FlushPkt) {
+ r.seenFlush = true
+ }
+ return len(p), nil
+}
+
+func (r *pushCommandRecorder) GetActions() ([]*apievents.GitCommandAction, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ // Noop push (e.g. "Everything up-to-date")
+ if bytes.Equal(r.payload, pktline.FlushPkt) {
+ return nil, nil
+ }
+
+ request := packp.NewReferenceUpdateRequest()
+ if err := request.Decode(bytes.NewReader(r.payload)); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ var actions []*apievents.GitCommandAction
+ for _, command := range request.Commands {
+ actions = append(actions, &apievents.GitCommandAction{
+ Action: string(command.Action()),
+ Reference: string(command.Name),
+ Old: command.Old.String(),
+ New: command.New.String(),
+ })
+ }
+ return actions, nil
+}
diff --git a/lib/srv/git/audit_test.go b/lib/srv/git/audit_test.go
new file mode 100644
index 0000000000000..1392b50c74e88
--- /dev/null
+++ b/lib/srv/git/audit_test.go
@@ -0,0 +1,83 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ apievents "github.com/gravitational/teleport/api/types/events"
+)
+
+func TestCommandRecorder(t *testing.T) {
+ tests := []struct {
+ name string
+ sshCommand string
+ inputs [][]byte
+ wantActions []*apievents.GitCommandAction
+ }{
+ {
+ name: "fetch",
+ sshCommand: "git-upload-pack 'my-org/my-repo.git'",
+ inputs: [][]byte{pktline.FlushPkt},
+ wantActions: nil,
+ },
+ {
+ name: "no-op push",
+ sshCommand: "git-receive-pack 'my-org/my-repo.git'",
+ inputs: [][]byte{pktline.FlushPkt},
+ wantActions: nil,
+ },
+ {
+ name: "push with packfile",
+ sshCommand: "git-receive-pack 'my-org/my-repo.git'",
+ inputs: [][]byte{
+ []byte("00af8a43aa31be3cb1816c8d517d34d61795613300f5 75ad3a489c1537ed064caa874ee38076b5a126be refs/heads/STeve/test\x00 report-status-v2 side-band-64k object-format=sha1 agent=git/2.45.00000"),
+ []byte("PACK-FILE-SHOULD-BE-IGNORED"),
+ },
+ wantActions: []*apievents.GitCommandAction{{
+ Action: "update",
+ Reference: "refs/heads/STeve/test",
+ Old: "8a43aa31be3cb1816c8d517d34d61795613300f5",
+ New: "75ad3a489c1537ed064caa874ee38076b5a126be",
+ }},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ command, err := ParseSSHCommand(tt.sshCommand)
+ require.NoError(t, err)
+
+ recorder := NewCommandRecorder(context.Background(), *command)
+ for _, input := range tt.inputs {
+ n, err := recorder.Write(input)
+ require.NoError(t, err)
+ require.Equal(t, len(input), n)
+ }
+ actions, err := recorder.GetActions()
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantActions, actions)
+ })
+ }
+}
diff --git a/lib/srv/git/command.go b/lib/srv/git/command.go
new file mode 100644
index 0000000000000..9ce7cad2c5dbf
--- /dev/null
+++ b/lib/srv/git/command.go
@@ -0,0 +1,130 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package git
+
+import (
+ "strings"
+
+ "github.com/go-git/go-git/v5/plumbing/transport"
+ "github.com/gravitational/trace"
+ "github.com/mattn/go-shellwords"
+
+ "github.com/gravitational/teleport/api/types"
+)
+
+// Repository is the repository path in the SSH command.
+type Repository string
+
+// Owner returns the first part of the repository path. If repository does not
+// have multiple parts, empty string will be returned.
+//
+// For GitHub, owner is either the user or the organization that owns the repo.
+func (r Repository) Owner() string {
+ if owner, _, ok := strings.Cut(string(r), "/"); ok {
+ return owner
+ }
+ return ""
+}
+
+// Command is the Git command to be executed.
+type Command struct {
+ // SSHCommand is the original SSH command.
+ SSHCommand string
+ // Service is the git service of the command (either git-upload-pack or
+ // git-receive-pack).
+ Service string
+ // Repository returns the repository path of the command.
+ Repository Repository
+}
+
+// ParseSSHCommand parses the provided SSH command and returns the plumbing
+// command details.
+func ParseSSHCommand(sshCommand string) (*Command, error) {
+ args, err := shellwords.Parse(sshCommand)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if len(args) == 0 {
+ return nil, trace.BadParameter("invalid SSH command %s", sshCommand)
+ }
+
+ // There are a number of plumbing commands but only upload-pack and
+ // receive-pack are expected over SSH transport.
+ // https://git-scm.com/docs/pack-protocol#_transports
+ switch args[0] {
+ // git-receive-pack - Receive what is pushed into the repository
+ // Example: git-receive-pack 'my-org/my-repo.git'
+ // https://git-scm.com/docs/git-receive-pack
+ case transport.ReceivePackServiceName:
+ if len(args) != 2 {
+ return nil, trace.CompareFailed("invalid SSH command %s: expecting 2 arguments for %q but got %d", sshCommand, args[0], len(args))
+ }
+ return &Command{
+ SSHCommand: sshCommand,
+ Service: args[0],
+ Repository: Repository(args[1]),
+ }, nil
+
+ // git-upload-pack - Send objects packed back to git-fetch-pack
+ // Example: git-upload-pack 'my-org/my-repo.git'
+ // https://git-scm.com/docs/git-upload-pack
+ case transport.UploadPackServiceName:
+ args = skipSSHCommandFlags(args)
+ if len(args) != 2 {
+ return nil, trace.CompareFailed("invalid SSH command %s: expecting 2 non-flag arguments for %q but got %d", sshCommand, args[0], len(args))
+ }
+
+ return &Command{
+ SSHCommand: sshCommand,
+ Service: args[0],
+ Repository: Repository(args[1]),
+ }, nil
+ default:
+ return nil, trace.BadParameter("unsupported SSH command %q", sshCommand)
+ }
+}
+
+// skipSSHCommandFlags filters out flags from the provided arguments.
+//
+// A flag typically has "--" as prefix. If a flag requires a value, it is
+// specified in the same arg with "=" (e.g. "--timeout=60") so there is no need
+// to skip an extra arg.
+func skipSSHCommandFlags(args []string) (ret []string) {
+ for _, arg := range args {
+ if !strings.HasPrefix(arg, "-") {
+ ret = append(ret, arg)
+ }
+ }
+ return
+}
+
+// checkSSHCommand performs basic checks against the SSH command.
+func checkSSHCommand(server types.Server, command *Command) error {
+ // Only supporting GitHub for now.
+ if server.GetGitHub() == nil {
+ return trace.BadParameter("the git_server is misconfigured due to missing GitHub spec, contact your Teleport administrator")
+ }
+ if server.GetGitHub().Organization != command.Repository.Owner() {
+ return trace.AccessDenied("expect organization %q but got %q",
+ server.GetGitHub().Organization,
+ command.Repository.Owner(),
+ )
+ }
+ return nil
+}
diff --git a/lib/srv/git/command_test.go b/lib/srv/git/command_test.go
new file mode 100644
index 0000000000000..0cae75d071570
--- /dev/null
+++ b/lib/srv/git/command_test.go
@@ -0,0 +1,148 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package git
+
+import (
+ "testing"
+
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+)
+
+func TestParseSSHCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ checkError require.ErrorAssertionFunc
+ wantOutput *Command
+ }{
+ {
+ name: "git-upload-pack",
+ input: "git-upload-pack 'my-org/my-repo.git'",
+ checkError: require.NoError,
+ wantOutput: &Command{
+ SSHCommand: "git-upload-pack 'my-org/my-repo.git'",
+ Service: "git-upload-pack",
+ Repository: "my-org/my-repo.git",
+ },
+ },
+ {
+ name: "git-upload-pack with double quote",
+ input: "git-upload-pack \"my-org/my-repo.git\"",
+ checkError: require.NoError,
+ wantOutput: &Command{
+ SSHCommand: "git-upload-pack \"my-org/my-repo.git\"",
+ Service: "git-upload-pack",
+ Repository: "my-org/my-repo.git",
+ },
+ },
+ {
+ name: "git-upload-pack with args",
+ input: "git-upload-pack --strict 'my-org/my-repo.git'",
+ checkError: require.NoError,
+ wantOutput: &Command{
+ SSHCommand: "git-upload-pack --strict 'my-org/my-repo.git'",
+ Service: "git-upload-pack",
+ Repository: "my-org/my-repo.git",
+ },
+ },
+ {
+ name: "git-upload-pack with args after repo",
+ input: "git-upload-pack --strict 'my-org/my-repo.git' --timeout=60",
+ checkError: require.NoError,
+ wantOutput: &Command{
+ SSHCommand: "git-upload-pack --strict 'my-org/my-repo.git' --timeout=60",
+ Service: "git-upload-pack",
+ Repository: "my-org/my-repo.git",
+ },
+ },
+ {
+ name: "missing quote",
+ input: "git-upload-pack 'my-org/my-repo.git",
+ checkError: require.Error,
+ },
+ {
+ name: "git-receive-pack",
+ input: "git-receive-pack 'my-org/my-repo.git'",
+ checkError: require.NoError,
+ wantOutput: &Command{
+ SSHCommand: "git-receive-pack 'my-org/my-repo.git'",
+ Service: "git-receive-pack",
+ Repository: "my-org/my-repo.git",
+ },
+ },
+ {
+ name: "missing args",
+ input: "git-receive-pack",
+ checkError: require.Error,
+ },
+ {
+ name: "unsupported",
+ input: "git-cat-file",
+ checkError: require.Error,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := ParseSSHCommand(tt.input)
+ tt.checkError(t, err)
+ require.Equal(t, tt.wantOutput, output)
+ })
+ }
+}
+
+func Test_checkSSHCommand(t *testing.T) {
+ server, err := types.NewGitHubServer(types.GitHubServerMetadata{
+ Integration: "my-org",
+ Organization: "my-org",
+ })
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ server types.Server
+ sshCommand string
+ checkError require.ErrorAssertionFunc
+ }{
+ {
+ name: "success",
+ server: server,
+ sshCommand: "git-upload-pack 'my-org/my-repo.git'",
+ checkError: require.NoError,
+ },
+ {
+ name: "org does not match",
+ server: server,
+ sshCommand: "git-upload-pack 'some-other-org/my-repo.git'",
+ checkError: func(t require.TestingT, err error, i ...interface{}) {
+ require.True(t, trace.IsAccessDenied(err), i...)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ command, err := ParseSSHCommand(tt.sshCommand)
+ require.NoError(t, err)
+ tt.checkError(t, checkSSHCommand(tt.server, command))
+ })
+ }
+}