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)) + }) + } +}