Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitHub proxy: git command recorder #50505

Merged
merged 5 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
157 changes: 157 additions & 0 deletions lib/srv/git/audit.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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.
greedy52 marked this conversation as resolved.
Show resolved Hide resolved
//
// 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()
Tener marked this conversation as resolved.
Show resolved Hide resolved

// 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
}
83 changes: 83 additions & 0 deletions lib/srv/git/audit_test.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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)
})
}
}
130 changes: 130 additions & 0 deletions lib/srv/git/command.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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]),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we strip .git suffix from the path? Same below.

Copy link
Contributor Author

@greedy52 greedy52 Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is the repository path at git level, i think we should keep the original path with the suffix. If we need to extract <github-org>, <github-repo> from this path, we can do it separately.

}, 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
}
Loading
Loading