From 81e5e51c25353d71117958eb50ed2c596389910b Mon Sep 17 00:00:00 2001 From: STeve Huang Date: Fri, 20 Dec 2024 11:51:33 -0500 Subject: [PATCH 1/2] GitHub proxy: recording git command --- go.mod | 7 ++ go.sum | 24 +++++- lib/srv/git/audit.go | 142 ++++++++++++++++++++++++++++++++++++ lib/srv/git/audit_test.go | 75 +++++++++++++++++++ lib/srv/git/command.go | 115 +++++++++++++++++++++++++++++ lib/srv/git/command_test.go | 138 +++++++++++++++++++++++++++++++++++ 6 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 lib/srv/git/audit.go create mode 100644 lib/srv/git/audit_test.go create mode 100644 lib/srv/git/command.go create mode 100644 lib/srv/git/command_test.go diff --git a/go.mod b/go.mod index 4c2145a3afd93..f4b735d89b407 100644 --- a/go.mod +++ b/go.mod @@ -100,6 +100,7 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 github.com/ghodss/yaml v1.0.0 github.com/gizak/termui/v3 v3.1.0 + github.com/go-git/go-git/v5 v5.12.0 github.com/go-jose/go-jose/v3 v3.0.3 github.com/go-ldap/ldap/v3 v3.4.8 github.com/go-logr/logr v1.4.2 @@ -149,6 +150,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.20.4 + 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.7.2 // replaced @@ -339,6 +341,8 @@ require ( github.com/go-errors/errors v1.4.2 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -400,6 +404,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect @@ -467,6 +472,7 @@ require ( github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect github.com/pingcap/log v1.1.1-0.20230317032135-a0d097d16e22 // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20240930120915-74034d4ac243 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/xattr v0.4.10 // indirect @@ -546,6 +552,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/component-helpers v0.31.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/metrics v0.31.1 // indirect diff --git a/go.sum b/go.sum index e0b7780b45836..c25e45e5b5eb1 100644 --- a/go.sum +++ b/go.sum @@ -778,8 +778,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= -github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= -github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY+9ef8E= @@ -1242,6 +1242,14 @@ github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3 github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= +github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -1684,6 +1692,8 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -1815,6 +1825,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= @@ -1968,6 +1980,8 @@ github.com/pingcap/log v1.1.1-0.20230317032135-a0d097d16e22 h1:2SOzvGvE8beiC1Y4g github.com/pingcap/log v1.1.1-0.20230317032135-a0d097d16e22/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= github.com/pingcap/tidb/pkg/parser v0.0.0-20240930120915-74034d4ac243 h1:B3pF5adXRpuEDfSKY/bV2Lw+pPKtWH4FOaAX3Jx3X54= github.com/pingcap/tidb/pkg/parser v0.0.0-20240930120915-74034d4ac243/go.mod h1:dXcO3Ts6jUVE1VwBZp3wbVdGO4pi9MXY6IvL4L1z62g= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -2076,8 +2090,8 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500/go.mod h1:+njLrG5wSeoG4Ds61rFgEzKvenR2UHbjMoDHsczxly0= github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIGMa7oSwp8SQPp7Hb4ZiI38K3RNBKD2LLeEM= github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc= @@ -3128,6 +3142,8 @@ gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7 gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/lib/srv/git/audit.go b/lib/srv/git/audit.go new file mode 100644 index 0000000000000..4cc6abd536b88 --- /dev/null +++ b/lib/srv/git/audit.go @@ -0,0 +1,142 @@ +/* + * 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/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 +} + +// NewCommandRecorder returns a new Git command recorder. +func NewCommandRecorder(command Command) CommandRecorder { + // For now, only record details on the push. Fetch is not very interesting. + if command.Service == transport.ReceivePackServiceName { + return newPushCommandRecorder(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 { + return nil +} +func (r *noopRecorder) Write(p []byte) (int, error) { + return len(p), nil +} + +// pushCommandRecoder records actions for git-receive-pack. +type pushCommandRecorder struct { + Command + + logger *slog.Logger + payload []byte + mu sync.Mutex +} + +func newPushCommandRecorder(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 bytes.HasSuffix(r.payload, pktline.FlushPkt) { + if len(p) > 0 { + r.logger.Log(context.Background(), log.TraceLevel, "Discarding packet protocol", "packet_length", len(p)) + } + return len(p), nil + } + + r.logger.Log(context.Background(), log.TraceLevel, "Recording Git command in packet protocol", "packet", string(p)) + r.payload = append(r.payload, p...) + return len(p), nil +} + +func (r *pushCommandRecorder) GetActions() (actions []*apievents.GitCommandAction) { + r.mu.Lock() + defer r.mu.Unlock() + + // Noop push (e.g. "Everything up-to-date") + if bytes.Equal(r.payload, pktline.FlushPkt) { + return nil + } + + request := packp.NewReferenceUpdateRequest() + if err := request.Decode(bytes.NewReader(r.payload)); err != nil { + r.logger.WarnContext(context.Background(), "failed to decode push command", "error", err) + return + } + 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 +} diff --git a/lib/srv/git/audit_test.go b/lib/srv/git/audit_test.go new file mode 100644 index 0000000000000..3bb39af3db9ff --- /dev/null +++ b/lib/srv/git/audit_test.go @@ -0,0 +1,75 @@ +/* + * 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/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 + input []byte + wantActions []*apievents.GitCommandAction + }{ + { + name: "fetch", + sshCommand: "git-upload-pack 'my-org/my-repo.git'", + input: pktline.FlushPkt, + wantActions: nil, + }, + { + name: "no-op push", + sshCommand: "git-receive-pack 'my-org/my-repo.git'", + input: pktline.FlushPkt, + wantActions: nil, + }, + { + name: "push with packfile", + sshCommand: "git-receive-pack 'my-org/my-repo.git'", + input: []byte("00af8a43aa31be3cb1816c8d517d34d61795613300f5 75ad3a489c1537ed064caa874ee38076b5a126be refs/heads/STeve/test\x00 report-status-v2 side-band-64k object-format=sha1 agent=git/2.45.00000"), + 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(*command) + n, err := recorder.Write(tt.input) + require.NoError(t, err) + require.Equal(t, len(tt.input), n) + assert.Equal(t, tt.wantActions, recorder.GetActions()) + }) + } +} diff --git a/lib/srv/git/command.go b/lib/srv/git/command.go new file mode 100644 index 0000000000000..f3b1035bbb065 --- /dev/null +++ b/lib/srv/git/command.go @@ -0,0 +1,115 @@ +/* + * 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. If repository does not have +// multiple parts, empty 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-upload-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("expecting 2 arguments for %q, got %d", 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: + if len(args) < 2 { + return nil, trace.CompareFailed("expecting more than one arguments for %q, got %d", args[0], len(args)) + } + + return &Command{ + SSHCommand: sshCommand, + Service: args[0], + Repository: Repository(args[len(args)-1]), + }, nil + default: + return nil, trace.BadParameter("unsupported command %q", sshCommand) + } +} + +// 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("missing GitHub spec") + } + 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..42e5e99f6dad1 --- /dev/null +++ b/lib/srv/git/command_test.go @@ -0,0 +1,138 @@ +/* + * 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: "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)) + }) + } +} From 8019b2a230fe82ef2932878283be67e213e201f2 Mon Sep 17 00:00:00 2001 From: STeve Huang Date: Thu, 2 Jan 2025 13:23:45 -0500 Subject: [PATCH 2/2] address review --- lib/srv/git/audit.go | 33 ++++++++++++++++++--------------- lib/srv/git/audit_test.go | 21 ++++++++++++++------- lib/srv/git/command.go | 4 ++-- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/lib/srv/git/audit.go b/lib/srv/git/audit.go index 4cc6abd536b88..809890895834a 100644 --- a/lib/srv/git/audit.go +++ b/lib/srv/git/audit.go @@ -28,6 +28,7 @@ import ( "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" @@ -43,7 +44,7 @@ type CommandRecorder interface { // GetCommand returns basic info of the command. GetCommand() Command // GetActions returns the action details of the command. - GetActions() []*apievents.GitCommandAction + GetActions() ([]*apievents.GitCommandAction, error) } // NewCommandRecorder returns a new Git command recorder. @@ -69,8 +70,8 @@ func newNoopRecorder(command Command) *noopRecorder { func (r *noopRecorder) GetCommand() Command { return r.Command } -func (r *noopRecorder) GetActions() []*apievents.GitCommandAction { - return nil +func (r *noopRecorder) GetActions() ([]*apievents.GitCommandAction, error) { + return nil, nil } func (r *noopRecorder) Write(p []byte) (int, error) { return len(p), nil @@ -80,9 +81,10 @@ func (r *noopRecorder) Write(p []byte) (int, error) { type pushCommandRecorder struct { Command - logger *slog.Logger - payload []byte - mu sync.Mutex + logger *slog.Logger + payload []byte + mu sync.Mutex + seenFlush bool } func newPushCommandRecorder(command Command) *pushCommandRecorder { @@ -104,32 +106,33 @@ func (r *pushCommandRecorder) Write(p []byte) (int, error) { // comes after the command-list. // // https://git-scm.com/docs/pack-protocol#_reference_update_request_and_packfile_transfer - if bytes.HasSuffix(r.payload, pktline.FlushPkt) { - if len(p) > 0 { - r.logger.Log(context.Background(), log.TraceLevel, "Discarding packet protocol", "packet_length", len(p)) - } + if r.seenFlush { + r.logger.Log(context.Background(), log.TraceLevel, "Discarding packet protocol", "packet_length", len(p)) return len(p), nil } r.logger.Log(context.Background(), 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() (actions []*apievents.GitCommandAction) { +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 + return nil, nil } request := packp.NewReferenceUpdateRequest() if err := request.Decode(bytes.NewReader(r.payload)); err != nil { - r.logger.WarnContext(context.Background(), "failed to decode push command", "error", err) - return + return nil, trace.Wrap(err) } + var actions []*apievents.GitCommandAction for _, command := range request.Commands { actions = append(actions, &apievents.GitCommandAction{ Action: string(command.Action()), @@ -138,5 +141,5 @@ func (r *pushCommandRecorder) GetActions() (actions []*apievents.GitCommandActio New: command.New.String(), }) } - return + return actions, nil } diff --git a/lib/srv/git/audit_test.go b/lib/srv/git/audit_test.go index 3bb39af3db9ff..2f3de3543fd4b 100644 --- a/lib/srv/git/audit_test.go +++ b/lib/srv/git/audit_test.go @@ -32,25 +32,28 @@ func TestCommandRecorder(t *testing.T) { tests := []struct { name string sshCommand string - input []byte + inputs [][]byte wantActions []*apievents.GitCommandAction }{ { name: "fetch", sshCommand: "git-upload-pack 'my-org/my-repo.git'", - input: pktline.FlushPkt, + inputs: [][]byte{pktline.FlushPkt}, wantActions: nil, }, { name: "no-op push", sshCommand: "git-receive-pack 'my-org/my-repo.git'", - input: pktline.FlushPkt, + inputs: [][]byte{pktline.FlushPkt}, wantActions: nil, }, { name: "push with packfile", sshCommand: "git-receive-pack 'my-org/my-repo.git'", - input: []byte("00af8a43aa31be3cb1816c8d517d34d61795613300f5 75ad3a489c1537ed064caa874ee38076b5a126be refs/heads/STeve/test\x00 report-status-v2 side-band-64k object-format=sha1 agent=git/2.45.00000"), + 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", @@ -66,10 +69,14 @@ func TestCommandRecorder(t *testing.T) { require.NoError(t, err) recorder := NewCommandRecorder(*command) - n, err := recorder.Write(tt.input) + 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) - require.Equal(t, len(tt.input), n) - assert.Equal(t, tt.wantActions, recorder.GetActions()) + assert.Equal(t, tt.wantActions, actions) }) } } diff --git a/lib/srv/git/command.go b/lib/srv/git/command.go index f3b1035bbb065..f8c09e2b43e0d 100644 --- a/lib/srv/git/command.go +++ b/lib/srv/git/command.go @@ -31,8 +31,8 @@ import ( // Repository is the repository path in the SSH command. type Repository string -// Owner returns the first part of the repository. If repository does not have -// multiple parts, empty will be returned. +// 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 {