diff --git a/e2e/go.mod b/e2e/go.mod index ed358a56..d69523b4 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -71,7 +71,10 @@ require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/lipgloss v0.9.1 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/chzyer/readline v1.5.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/containerd/api v1.9.0 // indirect github.com/containerd/containerd/v2 v2.1.4 // indirect @@ -184,7 +187,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.7 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -193,7 +196,7 @@ require ( github.com/masterzen/winrm v0.0.0-20260407182533-5570be7f80cf // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/microsoft/go-mssqldb v1.10.0 // indirect @@ -250,9 +253,9 @@ require ( github.com/pion/dtls/v3 v3.1.4 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect github.com/pion/turn/v4 v4.0.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -265,7 +268,6 @@ require ( github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/quic-go/quic-go v0.59.1 // indirect - github.com/rivo/uniseg v0.2.0 // indirect github.com/rs/cors v1.11.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index e873f8fb..f87948b2 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -187,6 +187,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -197,6 +199,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -662,8 +668,8 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+ github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -686,8 +692,8 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -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-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -857,12 +863,12 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= -github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -908,7 +914,6 @@ github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRC github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/go.mod b/go.mod index e5acef0f..a51f4bb0 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.27.2 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/charmbracelet/lipgloss v0.9.1 + github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.21 github.com/denisbrodbeck/machineid v1.0.1 github.com/dgraph-io/badger/v3 v3.2103.5 @@ -95,6 +96,8 @@ require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect @@ -152,12 +155,12 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.7 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -181,11 +184,10 @@ require ( github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sony/gobreaker v0.5.0 // indirect github.com/spf13/afero v1.6.0 // indirect diff --git a/go.sum b/go.sum index 993a0108..9fe5eb9e 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -151,6 +153,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -466,8 +472,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -486,8 +492,8 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -570,12 +576,12 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= -github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -594,7 +600,6 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/packages/pam/handlers/ssh/proxy.go b/packages/pam/handlers/ssh/proxy.go index 9b6eafeb..4ead932f 100644 --- a/packages/pam/handlers/ssh/proxy.go +++ b/packages/pam/handlers/ssh/proxy.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "sync" + "sync/atomic" "time" "github.com/Infisical/infisical-merge/packages/pam/session" @@ -32,30 +33,50 @@ type SSHProxyConfig struct { // SSHProxy handles proxying SSH connections with credential injection type SSHProxy struct { - config SSHProxyConfig - mutex sync.Mutex - sessionData []byte // Store session data for logging - inputBuffer []byte // Buffer for input data to batch keystrokes - inputChannelType session.SessionChannelType // Channel type for buffered input - escapeState int // 0=normal, 1=got ESC, 2=in CSI sequence - outputMutex sync.Mutex - outputBuffer []byte // Buffer for output data to enable masking across chunks - outputChannelType session.SessionChannelType // Channel type for buffered output + config SSHProxyConfig } -// channelState holds per-channel state for tracking session type +// channelState holds per-channel state. A client can open channels concurrently, so +// sharing recording state would interleave two channels into one transcript. type channelState struct { mutex sync.Mutex channelType session.SessionChannelType // Type of channel (terminal, exec, sftp) - isBinarySession bool // True if this channel is SFTP/SCP binary protocol - sftpParser *SFTPParser // Parser for SFTP protocol to extract file operations + isBinarySession bool // True if this channel is SFTP/SCP binary protocol + sftpParser *SFTPParser // Parser for SFTP protocol to extract file operations + + inputMutex sync.Mutex + inputBuffer []byte // Buffer for input data to batch keystrokes + inputChannelType session.SessionChannelType // Channel type for buffered input + inputScanner *inputSequenceFilter // Drops escape sequences from the input stream + pendingEcho echoedCommand // Command awaiting confirmation that the shell echoed it + echoBaseline int // Length of the line on screen when the command started + + outputMutex sync.Mutex + outputParser *terminalTranscript // Renders the output stream into displayed lines + outputChannelType session.SessionChannelType // Channel type for buffered output + outputPending atomic.Int64 // Rendered length of the line still on screen +} + +func newChannelState() *channelState { + return &channelState{ + inputScanner: newInputSequenceFilter(), + outputParser: newTerminalTranscript(), + } +} + +func (c *channelState) textEvent(eventType session.SessionEventType, channelType session.SessionChannelType, text string) session.SessionEvent { + return session.SessionEvent{ + Timestamp: time.Now(), + EventType: eventType, + ChannelType: channelType, + Data: []byte(text), + Rendered: true, + } } // NewSSHProxy creates a new SSH proxy instance func NewSSHProxy(config SSHProxyConfig) *SSHProxy { - return &SSHProxy{ - config: config, - } + return &SSHProxy{config: config} } // HandleConnection handles a single SSH client connection @@ -258,7 +279,7 @@ func (p *SSHProxy) handleChannel(ctx context.Context, newChannel ssh.NewChannel, Msg("SSH channel established") // Create per-channel state for tracking binary sessions (SFTP/SCP) - chState := &channelState{} + chState := newChannelState() // Separate done channels to ensure exit-status is forwarded before channel teardown. serverReqDone := make(chan struct{}) @@ -360,12 +381,8 @@ func (p *SSHProxy) handleChannelRequests(requests <-chan *ssh.Request, targetCha Msg("Blocked SSH exec command") // Log the blocked exec to session recording - blockedEvent := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: session.SessionChannelExec, - Data: []byte(fmt.Sprintf("$ %s\n[BLOCKED] Command not permitted\n", command)), - } + blockedEvent := chState.textEvent(session.SessionEventInput, session.SessionChannelExec, + fmt.Sprintf("$ %s\n[BLOCKED] Command not permitted", command)) if err := p.config.SessionLogger.LogSessionEvent(blockedEvent); err != nil { log.Error().Err(err).Str("sessionID", sessionID).Msg("Failed to log blocked exec command") } @@ -403,24 +420,19 @@ func (p *SSHProxy) handleChannelRequests(requests <-chan *ssh.Request, targetCha // scp -f /path = sending file FROM server if strings.Contains(command, " -t ") { path := extractSCPPath(command) - logMessage = fmt.Sprintf("Uploaded file: %s\n", path) + logMessage = fmt.Sprintf("Uploaded file: %s", path) } else if strings.Contains(command, " -f ") { path := extractSCPPath(command) - logMessage = fmt.Sprintf("Downloaded file: %s\n", path) + logMessage = fmt.Sprintf("Downloaded file: %s", path) } else { - logMessage = fmt.Sprintf("$ %s\n", command) + logMessage = fmt.Sprintf("$ %s", command) } } else { channelType = session.SessionChannelExec - logMessage = fmt.Sprintf("$ %s\n", command) + logMessage = fmt.Sprintf("$ %s", command) } - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: channelType, - Data: []byte(logMessage), - } + event := chState.textEvent(session.SessionEventInput, channelType, logMessage) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -456,12 +468,7 @@ func (p *SSHProxy) handleChannelRequests(requests <-chan *ssh.Request, targetCha chState.sftpParser = NewSFTPParser() chState.mutex.Unlock() - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: session.SessionChannelSFTP, - Data: []byte("File transfer session started\n"), - } + event := chState.textEvent(session.SessionEventInput, session.SessionChannelSFTP, "File transfer session started") if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -514,11 +521,12 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses // Flush any remaining buffers on exit defer func() { - if logInput && len(p.inputBuffer) > 0 { - p.flushInputBuffer(sessionID) + if logInput { + p.flushInputBuffer(sessionID, chState) + p.flushPendingEcho(sessionID, chState) } - if !logInput && len(p.outputBuffer) > 0 { - p.flushOutputBuffer(sessionID) + if !logInput { + p.flushOutputBuffer(sessionID, chState) } }() @@ -541,13 +549,8 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses operations := sftpParser.Parse(buf[:n]) for _, op := range operations { // Log each SFTP operation - logMsg := FormatOperation(op) + "\n" - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: session.SessionChannelSFTP, - Data: []byte(logMsg), - } + logMsg := FormatOperation(op) + event := chState.textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -565,10 +568,9 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses } else if !isBinary { // Regular terminal session logging if logInput { - p.bufferInput(buf[:n], sessionID, channelType) + p.bufferInput(buf[:n], sessionID, channelType, chState) } else { - // Buffer output until newline so masking patterns can match across echo chunks - p.bufferOutput(buf[:n], sessionID, channelType) + p.bufferOutput(buf[:n], sessionID, channelType, chState) } } @@ -594,148 +596,146 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses // bufferInput accumulates input data and logs the effective command after processing edits. // It interprets control characters (backspace, Ctrl+C/U/W) so that the logged command // reflects what the user actually sent, not the raw keystrokes. -func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType session.SessionChannelType) { - p.mutex.Lock() - defer p.mutex.Unlock() +func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType session.SessionChannelType, chState *channelState) { + chState.inputMutex.Lock() + defer chState.inputMutex.Unlock() - p.inputChannelType = channelType + chState.inputChannelType = channelType + typedThisRead := false for _, b := range data { - // Skip ANSI escape sequences (e.g., cursor position reports like ESC[11;17R) - // States: 0=normal, 1=got ESC, 2=in CSI sequence - if p.escapeState == 1 { - if b == '[' { - p.escapeState = 2 // ESC[ = CSI sequence start - } else { - p.escapeState = 0 // Two-byte escape sequence (ESC + char), done - } - continue - } - if p.escapeState == 2 { - // In CSI sequence: parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F) - // continue until final byte (0x40-0x7E) - if b >= 0x40 && b <= 0x7E { - p.escapeState = 0 // Final byte, sequence complete - } + if chState.inputScanner.consumed(b) { continue } switch b { - case 0x1B: // ESC - start of escape sequence - p.escapeState = 1 case 0x7F, 0x08: // DEL (backspace on most terminals) or BS - if len(p.inputBuffer) > 0 { - p.inputBuffer = p.inputBuffer[:len(p.inputBuffer)-1] + if len(chState.inputBuffer) > 0 { + chState.inputBuffer = chState.inputBuffer[:len(chState.inputBuffer)-1] } case 0x03: // Ctrl+C - cancel current input - p.inputBuffer = p.inputBuffer[:0] + chState.inputBuffer = chState.inputBuffer[:0] case 0x15: // Ctrl+U - clear line - p.inputBuffer = p.inputBuffer[:0] + chState.inputBuffer = chState.inputBuffer[:0] case 0x17: // Ctrl+W - delete previous word // Skip trailing spaces - for len(p.inputBuffer) > 0 && p.inputBuffer[len(p.inputBuffer)-1] == ' ' { - p.inputBuffer = p.inputBuffer[:len(p.inputBuffer)-1] + for len(chState.inputBuffer) > 0 && chState.inputBuffer[len(chState.inputBuffer)-1] == ' ' { + chState.inputBuffer = chState.inputBuffer[:len(chState.inputBuffer)-1] } // Delete until next space or start - for len(p.inputBuffer) > 0 && p.inputBuffer[len(p.inputBuffer)-1] != ' ' { - p.inputBuffer = p.inputBuffer[:len(p.inputBuffer)-1] + for len(chState.inputBuffer) > 0 && chState.inputBuffer[len(chState.inputBuffer)-1] != ' ' { + chState.inputBuffer = chState.inputBuffer[:len(chState.inputBuffer)-1] } case 0x0D, 0x0A: // CR or LF - flush the buffer - p.inputBuffer = append(p.inputBuffer, b) - p.flushInputBufferUnsafe(sessionID) + p.flushInputBufferUnsafe(sessionID, chState, typedThisRead) default: // Only buffer printable characters and tab if b >= 0x20 || b == 0x09 { - p.inputBuffer = append(p.inputBuffer, b) + if len(chState.inputBuffer) == 0 { + chState.echoBaseline = int(chState.outputPending.Load()) + } + chState.inputBuffer = append(chState.inputBuffer, b) + typedThisRead = true } // Safety: flush if buffer gets too large - if len(p.inputBuffer) >= 1024 { - p.flushInputBufferUnsafe(sessionID) + if len(chState.inputBuffer) >= 1024 { + p.flushInputBufferUnsafe(sessionID, chState, typedThisRead) } } } } // flushInputBuffer flushes the input buffer with locking -func (p *SSHProxy) flushInputBuffer(sessionID string) { - p.mutex.Lock() - defer p.mutex.Unlock() - p.flushInputBufferUnsafe(sessionID) +func (p *SSHProxy) flushInputBuffer(sessionID string, chState *channelState) { + chState.inputMutex.Lock() + defer chState.inputMutex.Unlock() + p.flushInputBufferUnsafe(sessionID, chState, false) } -// flushInputBufferUnsafe flushes the input buffer without locking (caller must hold lock) -func (p *SSHProxy) flushInputBufferUnsafe(sessionID string) { - if len(p.inputBuffer) == 0 { +// flushInputBufferUnsafe records the buffered command (caller must hold the lock). +// pasted means the command and its Enter arrived in one read, so nothing echoed yet. +func (p *SSHProxy) flushInputBufferUnsafe(sessionID string, chState *channelState, pasted bool) { + if len(chState.inputBuffer) == 0 { return } - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: p.inputChannelType, - Data: make([]byte, len(p.inputBuffer)), + command := string(chState.inputBuffer) + chState.inputBuffer = chState.inputBuffer[:0] + channel := chState.inputChannelType + + if channel != session.SessionChannelShell { + p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, command)) + return } - copy(event.Data, p.inputBuffer) + onScreen := int(chState.outputPending.Load()) + + // Nothing echoed means nothing will end the prompt's line either, so commit it + // here rather than let the next command's prompt land on the same line. + switch { + case onScreen > chState.echoBaseline: + // Echoed while typing, so that line is the record. + case pasted: + // Delivered ahead of any echo; the next committed line decides. + chState.pendingEcho.hold(command, channel, onScreen) + default: + p.flushOutputBuffer(sessionID, chState) + p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, command)) + } +} + +// flushPendingEcho records a held command that the shell never echoed back +func (p *SSHProxy) flushPendingEcho(sessionID string, chState *channelState) { + if event, ok := chState.pendingEcho.take(); ok { + p.logInputCommand(sessionID, event) + } +} + +func (p *SSHProxy) logInputCommand(sessionID string, event session.SessionEvent) { if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). Str("eventType", string(session.SessionEventInput)). Msg("Failed to log terminal event") } - - // Clear the buffer - p.inputBuffer = p.inputBuffer[:0] } -// bufferOutput accumulates output data and flushes on newline or size limit. -// This allows session log masking patterns to match across character-by-character echo, -// because the regex sees a full line rather than individual bytes. -func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType session.SessionChannelType) { - p.outputMutex.Lock() - defer p.outputMutex.Unlock() - - p.outputChannelType = channelType +// bufferOutput renders the output stream and logs one event per displayed line +func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType session.SessionChannelType, chState *channelState) { + chState.outputMutex.Lock() + defer chState.outputMutex.Unlock() - for _, b := range data { - p.outputBuffer = append(p.outputBuffer, b) + chState.outputChannelType = channelType + p.logOutputLines(sessionID, chState, chState.outputParser.Feed(data)) - // Flush on newline (LF) or if buffer gets too large - if b == 0x0A || len(p.outputBuffer) >= 4096 { - p.flushOutputBufferUnsafe(sessionID) - } - } + // Read without a lock by the input goroutine, which must not stall behind the + // session logger's fsync just to note where a command started. + chState.outputPending.Store(int64(chState.outputParser.PendingLen())) } -// flushOutputBuffer flushes the output buffer with locking -func (p *SSHProxy) flushOutputBuffer(sessionID string) { - p.outputMutex.Lock() - defer p.outputMutex.Unlock() - p.flushOutputBufferUnsafe(sessionID) +// flushOutputBuffer commits a partially rendered line +func (p *SSHProxy) flushOutputBuffer(sessionID string, chState *channelState) { + chState.outputMutex.Lock() + defer chState.outputMutex.Unlock() + p.logOutputLines(sessionID, chState, chState.outputParser.Flush()) + chState.outputPending.Store(0) } -// flushOutputBufferUnsafe flushes the output buffer without locking (caller must hold lock) -func (p *SSHProxy) flushOutputBufferUnsafe(sessionID string) { - if len(p.outputBuffer) == 0 { - return - } - - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventOutput, - ChannelType: p.outputChannelType, - Data: make([]byte, len(p.outputBuffer)), - } - copy(event.Data, p.outputBuffer) +// logOutputLines writes one output event per rendered line (caller must hold outputMutex) +func (p *SSHProxy) logOutputLines(sessionID string, chState *channelState, lines []string) { + for _, line := range lines { + event := chState.textEvent(session.SessionEventOutput, chState.outputChannelType, line) + if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { + log.Error().Err(err). + Str("sessionID", sessionID). + Str("eventType", string(session.SessionEventOutput)). + Msg("Failed to log terminal event") + } - if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { - log.Error().Err(err). - Str("sessionID", sessionID). - Str("eventType", string(session.SessionEventOutput)). - Msg("Failed to log terminal event") + if unechoed, ok := chState.pendingEcho.settle(line); ok { + p.logInputCommand(sessionID, unechoed) + } } - - p.outputBuffer = p.outputBuffer[:0] } // matchBlockedCommand checks if a command matches any blocked pattern. @@ -765,9 +765,8 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, Msg("Command blocking active for client→server proxy") defer func() { - if len(p.inputBuffer) > 0 { - p.flushInputBuffer(sessionID) - } + p.flushInputBuffer(sessionID, chState) + p.flushPendingEcho(sessionID, chState) }() for { @@ -788,13 +787,8 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, if sftpParser != nil { operations := sftpParser.Parse(buf[:n]) for _, op := range operations { - logMsg := FormatOperation(op) + "\n" - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: session.SessionChannelSFTP, - Data: []byte(logMsg), - } + logMsg := FormatOperation(op) + event := chState.textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) if logErr := p.config.SessionLogger.LogSessionEvent(event); logErr != nil { log.Error().Err(logErr). Str("sessionID", sessionID). @@ -818,38 +812,28 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, // Forward and log everything before this CR/LF if i > segStart { segment := buf[segStart:i] - p.bufferInput(segment, sessionID, channelType) + p.bufferInput(segment, sessionID, channelType, chState) if _, writeErr := dst.Write(segment); writeErr != nil { return writeErr } } // Check accumulated command against blocked patterns - p.mutex.Lock() - command := string(p.inputBuffer) - p.mutex.Unlock() + chState.inputMutex.Lock() + command := string(chState.inputBuffer) + chState.inputMutex.Unlock() if p.matchBlockedCommand(command) { - // BLOCKED: flush the typed command to session log, then log the block - p.mutex.Lock() - p.inputBuffer = append(p.inputBuffer, b) - p.flushInputBufferUnsafe(sessionID) - p.mutex.Unlock() - - // Flush pending output buffer so the echoed command appears before the blocked message - p.flushOutputBuffer(sessionID) + // Commit the echoed line before the notice so they read in order. + p.flushInputBuffer(sessionID, chState) + p.flushOutputBuffer(sessionID, chState) // Send error message to client (red text) blockedMsg := "\r\n\033[31m[BLOCKED] Command not permitted\033[0m\r\n" clientWriter.Write([]byte(blockedMsg)) // Log the blocked message as output so it appears in session replay - blockedEvent := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventOutput, - ChannelType: channelType, - Data: []byte(blockedMsg), - } + blockedEvent := chState.textEvent(session.SessionEventOutput, channelType, "[BLOCKED] Command not permitted") if logErr := p.config.SessionLogger.LogSessionEvent(blockedEvent); logErr != nil { log.Error().Err(logErr).Str("sessionID", sessionID).Msg("Failed to log blocked command event") } @@ -864,7 +848,7 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, Msg("Blocked SSH command") } else { // Allowed — forward the CR/LF through normal path - p.bufferInput([]byte{b}, sessionID, channelType) + p.bufferInput([]byte{b}, sessionID, channelType, chState) if _, writeErr := dst.Write([]byte{b}); writeErr != nil { return writeErr } @@ -877,7 +861,7 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, // Forward remaining segment after last CR/LF (or the entire chunk if no CR/LF) if segStart < n { segment := buf[segStart:n] - p.bufferInput(segment, sessionID, channelType) + p.bufferInput(segment, sessionID, channelType, chState) if _, writeErr := dst.Write(segment); writeErr != nil { return writeErr } diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go new file mode 100644 index 00000000..8bcd7bee --- /dev/null +++ b/packages/pam/handlers/ssh/terminal.go @@ -0,0 +1,270 @@ +package ssh + +import ( + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/Infisical/infisical-merge/packages/pam/session" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/parser" +) + +const ( + maxLineRunes = 8192 + tabWidth = 8 +) + +type terminalTranscript struct { + parser *ansi.Parser + line []rune + cursor int +} + +func newTerminalTranscript() *terminalTranscript { + return &terminalTranscript{parser: ansi.NewParser()} +} + +func (t *terminalTranscript) Feed(data []byte) []string { + var lines []string + for _, b := range data { + // An OSC payload can end in a byte that looks like a CSI final. + fromCSI := isCSIState(t.parser.State()) + + switch t.parser.Advance(b) { + case parser.PrintAction: + t.writeRune(t.parser.Rune(), &lines) + case parser.ExecuteAction: + t.execute(t.parser.Control(), &lines) + case parser.DispatchAction: + if fromCSI { + t.dispatchCSI(&lines) + } + } + } + return lines +} + +func (t *terminalTranscript) Flush() []string { + return appendLine(nil, t.commit()) +} + +func (t *terminalTranscript) PendingLen() int { + n := len(t.line) + for n > 0 && t.line[n-1] == ' ' { + n-- + } + return n +} + +func isCSIState(state parser.State) bool { + return state == parser.CsiEntryState || state == parser.CsiParamState || + state == parser.CsiIntermediateState +} + +func (t *terminalTranscript) execute(control byte, lines *[]string) { + switch control { + case '\n', 0x0B, 0x0C: + *lines = appendLine(*lines, t.commit()) + case '\r': + t.cursor = 0 + case 0x08: + t.cursor = max(t.cursor-1, 0) + case '\t': + for stop := (t.cursor/tabWidth + 1) * tabWidth; t.cursor < stop; { + t.writeRune(' ', lines) + } + } +} + +func (t *terminalTranscript) dispatchCSI(lines *[]string) { + switch t.parser.Command() { + case 'K', 'J': // erase in line, erase in display + switch t.param(0) { + case 1: + t.blank(0, t.cursor) + case 2, 3: + t.line, t.cursor = t.line[:0], 0 + default: + t.line = t.line[:min(t.cursor, len(t.line))] + } + case 'C': // cursor forward + t.cursor = clampColumn(t.cursor + t.param(1)) + case 'D': // cursor back + t.cursor = clampColumn(t.cursor - t.param(1)) + case 'G', '`': // absolute column + t.cursor = clampColumn(t.param(1) - 1) + case 'P': // delete characters + t.deleteChars(t.param(1)) + case '@': // insert blanks + t.insertBlanks(t.param(1)) + case 'X': // erase characters + t.blank(t.cursor, t.cursor+t.param(1)) + case 'A', 'B', 'E', 'F', 'H', 'f', 'd': // vertical and absolute moves; no rows to move between + *lines = appendLine(*lines, t.commit()) + } +} + +func (t *terminalTranscript) param(fallback int) int { + n, _ := t.parser.Param(0, fallback) + return min(max(n, 0), maxLineRunes) +} + +func (t *terminalTranscript) commit() string { + line := strings.TrimRight(string(t.line), " ") + t.line, t.cursor = t.line[:0], 0 + return line +} + +// A stream with no newline wraps at the line limit rather than dropping the rest. +func (t *terminalTranscript) writeRune(r rune, lines *[]string) { + if t.cursor >= maxLineRunes { + *lines = appendLine(*lines, t.commit()) + } + t.padTo(t.cursor) + if t.cursor < len(t.line) { + t.line[t.cursor] = r + } else { + t.line = append(t.line, r) + } + t.cursor++ +} + +func (t *terminalTranscript) padTo(col int) { + for len(t.line) < col { + t.line = append(t.line, ' ') + } +} + +func (t *terminalTranscript) blank(start, end int) { + for i := start; i < min(end, len(t.line)); i++ { + t.line[i] = ' ' + } +} + +func (t *terminalTranscript) deleteChars(n int) { + if n <= 0 || t.cursor >= len(t.line) { + return + } + t.line = append(t.line[:t.cursor], t.line[min(t.cursor+n, len(t.line)):]...) +} + +func (t *terminalTranscript) insertBlanks(n int) { + t.padTo(t.cursor) + if n = min(n, maxLineRunes-len(t.line)); n <= 0 { + return + } + t.line = append(t.line, make([]rune, n)...) + copy(t.line[t.cursor+n:], t.line[t.cursor:]) + t.blank(t.cursor, t.cursor+n) +} + +func clampColumn(col int) int { + return min(max(col, 0), maxLineRunes) +} + +func appendLine(lines []string, line string) []string { + if line == "" { + return lines + } + return append(lines, line) +} + +// inputSequenceFilter drops escape sequences, which can straddle a read. +type inputSequenceFilter struct { + parser *ansi.Parser +} + +func newInputSequenceFilter() *inputSequenceFilter { + return &inputSequenceFilter{parser: ansi.NewParser()} +} + +func (f *inputSequenceFilter) consumed(b byte) bool { + if f.parser.State() == parser.EscapeState && isStringIntroducer(b) { + f.parser.Reset() + return true + } + if isStringState(f.parser.State()) { + f.parser.Reset() + } + + switch f.parser.Advance(b) { + case parser.PrintAction, parser.ExecuteAction: + return false + default: + return true + } +} + +func isStringIntroducer(b byte) bool { + return b == ']' || b == 'P' || b == 'X' || b == '^' || b == '_' +} + +func isStringState(state parser.State) bool { + switch state { + case parser.OscStringState, parser.DcsStringState, parser.SosStringState, + parser.PmStringState, parser.ApcStringState: + return true + default: + return false + } +} + +// echoedCommand holds a command until the output shows whether the shell echoed +// it. The echoed line is the better record: it carries the prompt and reflects +// tab completion and history recall. +type echoedCommand struct { + mutex sync.Mutex + text string + timestamp time.Time + channel session.SessionChannelType + baseline int +} + +// hold takes a command with the length of the line on screen when typing began. +// A command displaced before any line committed is dropped: a multi-line paste +// delivers every command ahead of its echo, so claiming no echo would be wrong. +func (e *echoedCommand) hold(text string, channel session.SessionChannelType, baseline int) { + e.mutex.Lock() + defer e.mutex.Unlock() + + e.text, e.timestamp, e.channel, e.baseline = text, time.Now(), channel, baseline +} + +// settle applies a held command to the first line committed after it. A longer line +// means the shell echoed it, so that line is the record. +func (e *echoedCommand) settle(line string) (session.SessionEvent, bool) { + e.mutex.Lock() + defer e.mutex.Unlock() + + if e.text == "" { + return session.SessionEvent{}, false + } + if utf8.RuneCountInString(line) > e.baseline { + e.text = "" + return session.SessionEvent{}, false + } + return e.takeUnsafe() +} + +func (e *echoedCommand) take() (session.SessionEvent, bool) { + e.mutex.Lock() + defer e.mutex.Unlock() + return e.takeUnsafe() +} + +func (e *echoedCommand) takeUnsafe() (session.SessionEvent, bool) { + if e.text == "" { + return session.SessionEvent{}, false + } + event := session.SessionEvent{ + Timestamp: e.timestamp, + EventType: session.SessionEventInput, + ChannelType: e.channel, + Data: []byte(e.text), + Rendered: true, + } + e.text = "" + return event, true +} diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go new file mode 100644 index 00000000..5644c58e --- /dev/null +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -0,0 +1,339 @@ +package ssh + +import ( + "io" + "regexp" + "slices" + "strings" + "sync" + "testing" + + "github.com/Infisical/infisical-merge/packages/pam/session" +) + +const ( + esc = "\x1b" + bel = "\x07" +) + +// Default bash PS1: an OSC title whose payload holds '@', ':', ' ' and '/'. +func prompt(cwd string) string { + return esc + "]0;user@host: " + cwd + bel + + esc + "[01;32muser@host" + esc + "[00m:" + esc + "[01;34m" + cwd + esc + "[00m$ " +} + +func render(chunks ...string) []string { + t := newTerminalTranscript() + var got []string + for _, c := range chunks { + got = append(got, t.Feed([]byte(c))...) + } + return append(got, t.Flush()...) +} + +func TestTerminalTranscript(t *testing.T) { + tests := []struct { + name string + chunks []string + want []string + }{ + {"osc title and coloured prompt", []string{prompt("~") + "ls\r\n"}, []string{"user@host:~$ ls"}}, + {"osc payload split across reads", []string{esc + "]0;user@ho", "st: ~" + bel + "$ id\n"}, []string{"$ id"}}, + {"csi split across reads", []string{"ab" + esc + "[", "1;32mcd\n"}, []string{"abcd"}}, + {"osc terminated by st", []string{esc + "]0;user@host: /var" + esc + "\\done\n"}, []string{"done"}}, + {"charset designator", []string{esc + "(B" + esc + "[m% ls\n"}, []string{"% ls"}}, + {"redraw overwrites", []string{prompt("~") + "\r" + prompt("~") + "ls\r\n"}, []string{"user@host:~$ ls"}}, + {"erase line after redraw", []string{"cat f\rls" + esc + "[K\n"}, []string{"ls"}}, + {"backspace echo", []string{"lsx\b \bs\n"}, []string{"lss"}}, + {"tabs and blank lines", []string{"a\tb\n\r\n\r\nc\n"}, []string{"a b", "c"}}, + {"vertical move commits", []string{"one" + esc + "[2Btwo\n"}, []string{"one", "two"}}, + {"clear screen drops pending", []string{"stale" + esc + "[2Jfresh\n"}, []string{"fresh"}}, + {"multibyte rune split", []string{"caf\xc3", "\xa9 \xe2\x9c\x93\n"}, []string{"café ✓"}}, + {"cursor moves and edits", []string{"abcdef" + esc + "[3D" + esc + "[2P\n", "ab" + esc + "[4Ccd\n"}, []string{"abcf", "ab cd"}}, + {"unterminated osc recovers", []string{esc + "]0;no terminator", esc + "[0mrecovered\n"}, []string{"recovered"}}, + {"overlong line wraps instead of dropping", []string{strings.Repeat("a", maxLineRunes) + "bb\n"}, + []string{strings.Repeat("a", maxLineRunes), "bb"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := render(tt.chunks...); !slices.Equal(got, tt.want) { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestTranscriptBoundsPathologicalInput(t *testing.T) { + tr := newTerminalTranscript() + tr.Feed([]byte(strings.Repeat("x", 100_000))) + tr.Feed([]byte(esc + "[999999999999C" + esc + "[999999999999@")) + + // Insert-character returned to column zero must not extend the line each time. + tr.Feed([]byte(strings.Repeat("a", maxLineRunes))) + for range 8 { + tr.Feed([]byte("\r" + esc + "[8192@")) + } + + if n := len(tr.line); n > maxLineRunes { + t.Errorf("line grew to %d runes, want at most %d", n, maxLineRunes) + } +} + +type recordingLogger struct { + mutex sync.Mutex + events []session.SessionEvent +} + +func (l *recordingLogger) LogEntry(session.SessionLogEntry) error { return nil } +func (l *recordingLogger) LogHttpEvent(session.HttpEvent) error { return nil } +func (l *recordingLogger) Close() error { return nil } + +func (l *recordingLogger) LogSessionEvent(event session.SessionEvent) error { + l.mutex.Lock() + defer l.mutex.Unlock() + l.events = append(l.events, event) + return nil +} + +// typeLine models interactive typing: the shell echoes each keystroke before Enter. +func typeLine(p *SSHProxy, ch *channelState, channel session.SessionChannelType, text string) { + p.bufferInput([]byte(text), "sid", channel, ch) + if channel == session.SessionChannelShell { + emit(p, ch, text) + } + p.bufferInput([]byte{0x0D}, "sid", channel, ch) +} + +// typeSilently models a prompt with echo off: nothing comes back while typing. +func typeSilently(p *SSHProxy, ch *channelState, text string) { + p.bufferInput([]byte(text), "sid", session.SessionChannelShell, ch) + p.bufferInput([]byte{0x0D}, "sid", session.SessionChannelShell, ch) +} + +func emit(p *SSHProxy, ch *channelState, text string) { + p.bufferOutput([]byte(text), "sid", session.SessionChannelShell, ch) +} + +func TestSessionRecording(t *testing.T) { + shell := session.SessionChannelShell + + tests := []struct { + name string + steps func(p *SSHProxy, ch *channelState) + want []string + }{ + { + name: "echo replaces the command event", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")) + typeLine(p, ch, shell, "ls") + emit(p, ch, "\r\nLICENSE go\r\n"+prompt("~")) + }, + want: []string{"output: user@host:~$ ls", "output: LICENSE go", "output: user@host:~$"}, + }, + { + name: "tab completion counts as echoed", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~/cli")) + typeLine(p, ch, shell, "cd ..") + emit(p, ch, "/\r\n") + }, + want: []string{"output: user@host:~/cli$ cd ../"}, + }, + { + name: "paste arriving with its newline counts as echoed", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")) + p.bufferInput([]byte("cd ../cli\r"), "sid", shell, ch) + emit(p, ch, "cd ../cli\r\n") + }, + want: []string{"output: user@host:~$ cd ../cli"}, + }, + { + name: "unechoed input is recorded when it is typed", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")) + typeSilently(p, ch, "whoami") + emit(p, ch, "\r\nroot\r\n") + }, + want: []string{"output: user@host:~$", "input: whoami", "output: root"}, + }, + { + name: "multi-line paste counts as echoed", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")) + p.bufferInput([]byte("cd cli\nls\n"), "sid", shell, ch) + emit(p, ch, "cd cli\r\n"+prompt("~/cli")+"ls\r\nLICENSE go\r\n") + }, + want: []string{ + "output: user@host:~$ cd cli", + "output: user@host:~/cli$ ls", + "output: LICENSE go", + }, + }, + { + name: "input at a prompt that does not echo is recorded", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")+"read -s -p \"Enter name: \" x\r\nEnter name: ") + typeSilently(p, ch, "operator") + emit(p, ch, "\r\nok\r\n") + }, + want: []string{ + "output: user@host:~$ read -s -p \"Enter name: \" x", + "output: Enter name:", + "input: operator", + "output: ok", + }, + }, + { + name: "exec channel logs its command immediately", + steps: func(p *SSHProxy, ch *channelState) { typeLine(p, ch, session.SessionChannelExec, "uname -a") }, + want: []string{"input: uname -a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := &recordingLogger{} + p := NewSSHProxy(SSHProxyConfig{SessionLogger: logger}) + ch := newChannelState() + + tt.steps(p, ch) + p.flushOutputBuffer("sid", ch) + p.flushPendingEcho("sid", ch) + + got := make([]string, len(logger.events)) + for i, e := range logger.events { + if !e.Rendered || e.Timestamp.IsZero() { + t.Errorf("event %d (%q) lacks the rendered flag or a timestamp", i, e.Data) + } + got[i] = string(e.EventType) + ": " + string(e.Data) + } + if !slices.Equal(got, tt.want) { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestConcurrentChannelsKeepSeparateTranscripts(t *testing.T) { + p := NewSSHProxy(SSHProxyConfig{SessionLogger: &recordingLogger{}}) + + var wg sync.WaitGroup + for range 8 { + ch := newChannelState() + wg.Go(func() { + for range 200 { + typeLine(p, ch, session.SessionChannelShell, "ls -la") + } + }) + wg.Go(func() { + for range 200 { + emit(p, ch, prompt("~")+"ls -la\r\ntotal 0\r\n") + p.flushOutputBuffer("sid", ch) + } + }) + } + wg.Wait() +} + +// Channel state must not be shared: input has to stay attributed to its own channel. +func TestChannelsRecordIndependently(t *testing.T) { + logger := &recordingLogger{} + p := NewSSHProxy(SSHProxyConfig{SessionLogger: logger}) + shellCh, execCh := newChannelState(), newChannelState() + + emit(p, shellCh, prompt("~")) + p.bufferInput([]byte("whoami"), "sid", session.SessionChannelShell, shellCh) + typeLine(p, execCh, session.SessionChannelExec, "id") + p.bufferInput([]byte{0x0D}, "sid", session.SessionChannelShell, shellCh) + emit(p, shellCh, "\r\n") + p.flushOutputBuffer("sid", shellCh) + p.flushPendingEcho("sid", shellCh) + + for _, e := range logger.events { + if e.EventType != session.SessionEventInput { + continue + } + want := map[string]session.SessionChannelType{ + "whoami": session.SessionChannelShell, + "id": session.SessionChannelExec, + }[string(e.Data)] + if want == "" { + t.Fatalf("unexpected input event %q", e.Data) + } + if e.ChannelType != want { + t.Errorf("input %q recorded on channel %q, want %q", e.Data, e.ChannelType, want) + } + } +} + +// typedInput feeds keystrokes the way a client does, with the server's echo of +// each chunk arriving before the next one is read. +type typedInput struct { + p *SSHProxy + ch *channelState + chunks []string + i int +} + +func (r *typedInput) Read(b []byte) (int, error) { + if r.i > 0 { + emit(r.p, r.ch, r.chunks[r.i-1]) + } + if r.i >= len(r.chunks) { + return 0, io.EOF + } + n := copy(b, r.chunks[r.i]) + r.i++ + return n, nil +} + +// A blocked command is recorded once, by the echoed line, like any other. +func TestBlockedCommandIsNotDuplicated(t *testing.T) { + logger := &recordingLogger{} + p := NewSSHProxy(SSHProxyConfig{ + SessionLogger: logger, + BlockedCommandPatterns: []*regexp.Regexp{regexp.MustCompile(`sudo`)}, + }) + ch := newChannelState() + ch.channelType = session.SessionChannelShell + + emit(p, ch, prompt("~")) + src := &typedInput{p: p, ch: ch, chunks: []string{"sudo what", "\r"}} + if err := p.proxyClientToServerWithBlocking(src, io.Discard, io.Discard, "sid", ch); err != nil { + t.Fatalf("proxy returned %v", err) + } + p.flushOutputBuffer("sid", ch) + p.flushPendingEcho("sid", ch) + + var got []string + for _, e := range logger.events { + got = append(got, string(e.EventType)+": "+string(e.Data)) + } + want := []string{ + "output: user@host:~$ sudo what", + "output: [BLOCKED] Command not permitted", + } + if !slices.Equal(got, want) { + t.Errorf("got %q, want %q", got, want) + } +} + +// A string introducer in the keystroke stream must not swallow the command: that +// would hide it from command blocking and from the recording. +func TestInputFilterKeepsCommandAfterStringIntroducer(t *testing.T) { + for _, introducer := range []string{"]", "P", "X", "^", "_"} { + f := newInputSequenceFilter() + var kept []byte + for _, b := range []byte(esc + introducer + "rm -rf /\r") { + if !f.consumed(b) { + kept = append(kept, b) + } + } + if string(kept) != "rm -rf /\r" { + t.Errorf("ESC %s: kept %q, want %q", introducer, kept, "rm -rf /\r") + } + } +} diff --git a/packages/pam/session/logger.go b/packages/pam/session/logger.go index 8a443b01..9b710a9c 100644 --- a/packages/pam/session/logger.go +++ b/packages/pam/session/logger.go @@ -49,7 +49,8 @@ type SessionEvent struct { Timestamp time.Time `json:"timestamp"` EventType SessionEventType `json:"eventType"` ChannelType SessionChannelType `json:"channelType,omitempty"` // Channel kind (SSH shell/exec/sftp or RDP) - Data []byte `json:"data"` // SSH: raw terminal bytes; RDP: JSON envelope (base64-marshaled) + Data []byte `json:"data"` // SSH: gateway-rendered text; RDP: JSON envelope (base64-marshaled) + Rendered bool `json:"rendered,omitempty"` // Data is display-ready; absent on recordings that stored raw bytes ElapsedTime float64 `json:"elapsedTime"` // Seconds since session start (for replay) }