From 95c9a215ce9d279f52112a5741b19ce0ff0ed352 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 18 Aug 2026 14:25:01 -0400 Subject: [PATCH 1/8] feat(pam): ssh session log parsing in gateway --- go.mod | 8 +- go.sum | 15 +- packages/pam/handlers/ssh/proxy.go | 215 ++++++++--------- packages/pam/handlers/ssh/terminal.go | 256 +++++++++++++++++++++ packages/pam/handlers/ssh/terminal_test.go | 190 +++++++++++++++ packages/pam/session/logger.go | 3 +- 6 files changed, 560 insertions(+), 127 deletions(-) create mode 100644 packages/pam/handlers/ssh/terminal.go create mode 100644 packages/pam/handlers/ssh/terminal_test.go diff --git a/go.mod b/go.mod index e5acef0f..99df801f 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 @@ -185,7 +188,6 @@ require ( github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/transport/v4 v4.0.1 // 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..8aece601 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= @@ -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..18ba9680 100644 --- a/packages/pam/handlers/ssh/proxy.go +++ b/packages/pam/handlers/ssh/proxy.go @@ -8,7 +8,9 @@ import ( "regexp" "strings" "sync" + "sync/atomic" "time" + "unicode/utf8" "github.com/Infisical/infisical-merge/packages/pam/session" "github.com/Infisical/infisical-merge/packages/util" @@ -34,27 +36,42 @@ type SSHProxyConfig struct { 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 + sessionData []byte // Store session data for logging + 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 current command started + outputPending atomic.Int64 // Rendered length of the line still on screen outputMutex sync.Mutex - outputBuffer []byte // Buffer for output data to enable masking across chunks - outputChannelType session.SessionChannelType // Channel type for buffered output + outputParser *terminalTranscript // Renders the output stream into the lines a terminal would display + outputChannelType session.SessionChannelType // Channel type for buffered output } // channelState holds per-channel state for tracking session type 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 +} + +func 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, + config: config, + inputScanner: newInputSequenceFilter(), + outputParser: newTerminalTranscript(), } } @@ -360,12 +377,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 := 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 +416,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 := textEvent(session.SessionEventInput, channelType, logMessage) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -456,12 +464,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 := 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,10 +517,11 @@ 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 { + if logInput { p.flushInputBuffer(sessionID) + p.flushPendingEcho(sessionID) } - if !logInput && len(p.outputBuffer) > 0 { + if !logInput { p.flushOutputBuffer(sessionID) } }() @@ -541,13 +545,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 := textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -567,7 +566,6 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses if logInput { p.bufferInput(buf[:n], sessionID, channelType) } else { - // Buffer output until newline so masking patterns can match across echo chunks p.bufferOutput(buf[:n], sessionID, channelType) } } @@ -601,28 +599,11 @@ func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType sessio p.inputChannelType = channelType 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 p.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] @@ -641,11 +622,13 @@ func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType sessio p.inputBuffer = p.inputBuffer[:len(p.inputBuffer)-1] } case 0x0D, 0x0A: // CR or LF - flush the buffer - p.inputBuffer = append(p.inputBuffer, b) p.flushInputBufferUnsafe(sessionID) default: // Only buffer printable characters and tab if b >= 0x20 || b == 0x09 { + if len(p.inputBuffer) == 0 { + p.echoBaseline = int(p.outputPending.Load()) + } p.inputBuffer = append(p.inputBuffer, b) } // Safety: flush if buffer gets too large @@ -669,73 +652,76 @@ func (p *SSHProxy) flushInputBufferUnsafe(sessionID string) { return } - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventInput, - ChannelType: p.inputChannelType, - Data: make([]byte, len(p.inputBuffer)), + command := string(p.inputBuffer) + p.inputBuffer = p.inputBuffer[:0] + + if p.inputChannelType != session.SessionChannelShell { + p.logInputCommand(sessionID, textEvent(session.SessionEventInput, p.inputChannelType, command)) + return + } + + p.pendingEcho.hold(command, p.inputChannelType, p.echoBaseline) +} + +// flushPendingEcho records a held command that the shell never echoed back +func (p *SSHProxy) flushPendingEcho(sessionID string) { + if event, ok := p.pendingEcho.take(); ok { + p.logInputCommand(sessionID, event) } - copy(event.Data, p.inputBuffer) +} +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. +// bufferOutput renders the output stream and logs one event per displayed line func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType session.SessionChannelType) { p.outputMutex.Lock() defer p.outputMutex.Unlock() p.outputChannelType = channelType - for _, b := range data { - p.outputBuffer = append(p.outputBuffer, b) + p.logOutputLines(sessionID, p.outputParser.Feed(data)) - // Flush on newline (LF) or if buffer gets too large - if b == 0x0A || len(p.outputBuffer) >= 4096 { - p.flushOutputBufferUnsafe(sessionID) - } + pending := p.outputParser.PendingLen() + if pending >= maxLineRunes/2 { + p.logOutputLines(sessionID, p.outputParser.Flush()) + pending = 0 } + // 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. + p.outputPending.Store(int64(pending)) } -// flushOutputBuffer flushes the output buffer with locking +// flushOutputBuffer commits a partially rendered line func (p *SSHProxy) flushOutputBuffer(sessionID string) { p.outputMutex.Lock() defer p.outputMutex.Unlock() - p.flushOutputBufferUnsafe(sessionID) + p.logOutputLines(sessionID, p.outputParser.Flush()) + p.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 - } +// logOutputLines writes one output event per rendered line (caller must hold outputMutex) +func (p *SSHProxy) logOutputLines(sessionID string, lines []string) { + for _, line := range lines { + event := textEvent(session.SessionEventOutput, p.outputChannelType, line) - event := session.SessionEvent{ - Timestamp: time.Now(), - EventType: session.SessionEventOutput, - ChannelType: p.outputChannelType, - Data: make([]byte, len(p.outputBuffer)), - } - copy(event.Data, p.outputBuffer) + 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 notice, ok := p.pendingEcho.resolve(utf8.RuneCountInString(line)); ok { + p.logInputCommand(sessionID, notice) + } } - - p.outputBuffer = p.outputBuffer[:0] } // matchBlockedCommand checks if a command matches any blocked pattern. @@ -765,9 +751,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) + p.flushPendingEcho(sessionID) }() for { @@ -788,13 +773,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 := textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) if logErr := p.config.SessionLogger.LogSessionEvent(event); logErr != nil { log.Error().Err(logErr). Str("sessionID", sessionID). @@ -830,11 +810,15 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, p.mutex.Unlock() if p.matchBlockedCommand(command) { - // BLOCKED: flush the typed command to session log, then log the block + // Ctrl+U below wipes the echo, so record the command directly. + p.flushPendingEcho(sessionID) p.mutex.Lock() - p.inputBuffer = append(p.inputBuffer, b) - p.flushInputBufferUnsafe(sessionID) + typed := string(p.inputBuffer) + p.inputBuffer = p.inputBuffer[:0] p.mutex.Unlock() + if typed != "" { + p.logInputCommand(sessionID, textEvent(session.SessionEventInput, channelType, typed)) + } // Flush pending output buffer so the echoed command appears before the blocked message p.flushOutputBuffer(sessionID) @@ -844,12 +828,7 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, 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 := 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") } diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go new file mode 100644 index 00000000..78ab7405 --- /dev/null +++ b/packages/pam/handlers/ssh/terminal.go @@ -0,0 +1,256 @@ +package ssh + +import ( + "fmt" + "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()) + 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(' ') + } + } +} + +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 +} + +func (t *terminalTranscript) writeRune(r rune) { + if t.cursor >= maxLineRunes { + return + } + 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) { + if n = min(n, maxLineRunes-t.cursor); n <= 0 { + return + } + t.padTo(t.cursor) + 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 { + switch f.parser.Advance(b) { + case parser.PrintAction, parser.ExecuteAction: + return false + default: + return true + } +} + +// 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 +} + +// resolve settles a held command against the first line committed after it. A +// line longer than what was on screen when typing began was echoed, so the +// echoed line stands in for the command; otherwise echo was off. +func (e *echoedCommand) resolve(lineRunes int) (session.SessionEvent, bool) { + e.mutex.Lock() + defer e.mutex.Unlock() + + if e.text == "" { + return session.SessionEvent{}, false + } + if lineRunes > 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 + } + // Echo off means a secret prompt; recording the keystrokes would log the password. + count := utf8.RuneCountInString(e.text) + notice := fmt.Sprintf("[no echo] %d characters submitted", count) + if count == 1 { + notice = "[no echo] 1 character submitted" + } + + event := session.SessionEvent{ + Timestamp: e.timestamp, + EventType: session.SessionEventInput, + ChannelType: e.channel, + Data: []byte(notice), + 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..25811dc1 --- /dev/null +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -0,0 +1,190 @@ +package ssh + +import ( + "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"}}, + } + + 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@")) + + if n := tr.PendingLen(); n > maxLineRunes { + t.Errorf("pending 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 +} + +func typeLine(p *SSHProxy, channel session.SessionChannelType, text string) { + p.bufferInput([]byte(text), "sid", channel) + p.bufferInput([]byte{0x0D}, "sid", channel) +} + +func emit(p *SSHProxy, text string) { + p.bufferOutput([]byte(text), "sid", session.SessionChannelShell) +} + +func TestSessionRecording(t *testing.T) { + shell := session.SessionChannelShell + + tests := []struct { + name string + steps func(p *SSHProxy) + want []string + }{ + { + name: "echo replaces the command event", + steps: func(p *SSHProxy) { + emit(p, prompt("~")) + typeLine(p, shell, "ls") + emit(p, "ls\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) { + emit(p, prompt("~/cli")) + typeLine(p, shell, "cd ..\t") + emit(p, "cd ../\r\n") + }, + want: []string{"output: user@host:~/cli$ cd ../"}, + }, + { + name: "paste arriving with its newline counts as echoed", + steps: func(p *SSHProxy) { + emit(p, prompt("~")) + p.bufferInput([]byte("cd ../cli\r"), "sid", shell) + emit(p, "cd ../cli\r\n") + }, + want: []string{"output: user@host:~$ cd ../cli"}, + }, + { + name: "unechoed input is recorded without its content", + steps: func(p *SSHProxy) { + emit(p, "[sudo] password for deploy: ") + typeLine(p, shell, "s3cr3t!!") + emit(p, "\r\nroot\r\n") + }, + want: []string{ + "output: [sudo] password for deploy:", + "input: [no echo] 8 characters submitted", + "output: root", + }, + }, + { + name: "multi-line paste counts as echoed", + steps: func(p *SSHProxy) { + emit(p, prompt("~")) + p.bufferInput([]byte("cd cli\nls\n"), "sid", shell) + emit(p, "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: "exec channel logs its command immediately", + steps: func(p *SSHProxy) { typeLine(p, 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}) + + tt.steps(p) + p.flushOutputBuffer("sid") + p.flushPendingEcho("sid") + + 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) + } + if strings.Contains(string(e.Data), "s3cr3t") { + t.Errorf("event %d recorded the typed secret", i) + } + got[i] = string(e.EventType) + ": " + string(e.Data) + } + if !slices.Equal(got, tt.want) { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} 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) } From 297745300672f7a52fbd3bd22738e7d891489b81 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 18 Aug 2026 16:37:04 -0400 Subject: [PATCH 2/8] address reviews --- packages/pam/handlers/ssh/proxy.go | 180 ++++++++++----------- packages/pam/handlers/ssh/terminal.go | 9 +- packages/pam/handlers/ssh/terminal_test.go | 105 ++++++++---- 3 files changed, 170 insertions(+), 124 deletions(-) diff --git a/packages/pam/handlers/ssh/proxy.go b/packages/pam/handlers/ssh/proxy.go index 18ba9680..2c837d75 100644 --- a/packages/pam/handlers/ssh/proxy.go +++ b/packages/pam/handlers/ssh/proxy.go @@ -34,26 +34,36 @@ 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 - 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 current command started - outputPending atomic.Int64 // Rendered length of the line still on screen - outputMutex sync.Mutex - outputParser *terminalTranscript // Renders the output stream into the lines a terminal would display - 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 recording state lives here: sharing it would interleave two channels into +// one transcript and let one channel's type decide another's redaction. 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 + + 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 textEvent(eventType session.SessionEventType, channelType session.SessionChannelType, text string) session.SessionEvent { @@ -68,11 +78,7 @@ func textEvent(eventType session.SessionEventType, channelType session.SessionCh // NewSSHProxy creates a new SSH proxy instance func NewSSHProxy(config SSHProxyConfig) *SSHProxy { - return &SSHProxy{ - config: config, - inputScanner: newInputSequenceFilter(), - outputParser: newTerminalTranscript(), - } + return &SSHProxy{config: config} } // HandleConnection handles a single SSH client connection @@ -275,7 +281,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{}) @@ -518,11 +524,11 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses // Flush any remaining buffers on exit defer func() { if logInput { - p.flushInputBuffer(sessionID) - p.flushPendingEcho(sessionID) + p.flushInputBuffer(sessionID, chState) + p.flushPendingEcho(sessionID, chState) } if !logInput { - p.flushOutputBuffer(sessionID) + p.flushOutputBuffer(sessionID, chState) } }() @@ -564,9 +570,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 { - p.bufferOutput(buf[:n], sessionID, channelType) + p.bufferOutput(buf[:n], sessionID, channelType, chState) } } @@ -592,80 +598,80 @@ 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 for _, b := range data { - if p.inputScanner.consumed(b) { + if chState.inputScanner.consumed(b) { continue } switch b { 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.flushInputBufferUnsafe(sessionID) + p.flushInputBufferUnsafe(sessionID, chState) default: // Only buffer printable characters and tab if b >= 0x20 || b == 0x09 { - if len(p.inputBuffer) == 0 { - p.echoBaseline = int(p.outputPending.Load()) + if len(chState.inputBuffer) == 0 { + chState.echoBaseline = int(chState.outputPending.Load()) } - p.inputBuffer = append(p.inputBuffer, b) + chState.inputBuffer = append(chState.inputBuffer, b) } // Safety: flush if buffer gets too large - if len(p.inputBuffer) >= 1024 { - p.flushInputBufferUnsafe(sessionID) + if len(chState.inputBuffer) >= 1024 { + p.flushInputBufferUnsafe(sessionID, chState) } } } } // 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) } // flushInputBufferUnsafe flushes the input buffer without locking (caller must hold lock) -func (p *SSHProxy) flushInputBufferUnsafe(sessionID string) { - if len(p.inputBuffer) == 0 { +func (p *SSHProxy) flushInputBufferUnsafe(sessionID string, chState *channelState) { + if len(chState.inputBuffer) == 0 { return } - command := string(p.inputBuffer) - p.inputBuffer = p.inputBuffer[:0] + command := string(chState.inputBuffer) + chState.inputBuffer = chState.inputBuffer[:0] - if p.inputChannelType != session.SessionChannelShell { - p.logInputCommand(sessionID, textEvent(session.SessionEventInput, p.inputChannelType, command)) + if chState.inputChannelType != session.SessionChannelShell { + p.logInputCommand(sessionID, textEvent(session.SessionEventInput, chState.inputChannelType, command)) return } - p.pendingEcho.hold(command, p.inputChannelType, p.echoBaseline) + chState.pendingEcho.hold(command, chState.inputChannelType, chState.echoBaseline) } // flushPendingEcho records a held command that the shell never echoed back -func (p *SSHProxy) flushPendingEcho(sessionID string) { - if event, ok := p.pendingEcho.take(); ok { +func (p *SSHProxy) flushPendingEcho(sessionID string, chState *channelState) { + if event, ok := chState.pendingEcho.take(); ok { p.logInputCommand(sessionID, event) } } @@ -680,36 +686,30 @@ func (p *SSHProxy) logInputCommand(sessionID string, event session.SessionEvent) } // bufferOutput renders the output stream and logs one event per displayed line -func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType session.SessionChannelType) { - p.outputMutex.Lock() - defer p.outputMutex.Unlock() +func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType session.SessionChannelType, chState *channelState) { + chState.outputMutex.Lock() + defer chState.outputMutex.Unlock() - p.outputChannelType = channelType + chState.outputChannelType = channelType + p.logOutputLines(sessionID, chState, chState.outputParser.Feed(data)) - p.logOutputLines(sessionID, p.outputParser.Feed(data)) - - pending := p.outputParser.PendingLen() - if pending >= maxLineRunes/2 { - p.logOutputLines(sessionID, p.outputParser.Flush()) - pending = 0 - } // 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. - p.outputPending.Store(int64(pending)) + chState.outputPending.Store(int64(chState.outputParser.PendingLen())) } // flushOutputBuffer commits a partially rendered line -func (p *SSHProxy) flushOutputBuffer(sessionID string) { - p.outputMutex.Lock() - defer p.outputMutex.Unlock() - p.logOutputLines(sessionID, p.outputParser.Flush()) - p.outputPending.Store(0) +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) } // logOutputLines writes one output event per rendered line (caller must hold outputMutex) -func (p *SSHProxy) logOutputLines(sessionID string, lines []string) { +func (p *SSHProxy) logOutputLines(sessionID string, chState *channelState, lines []string) { for _, line := range lines { - event := textEvent(session.SessionEventOutput, p.outputChannelType, line) + event := textEvent(session.SessionEventOutput, chState.outputChannelType, line) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). @@ -718,7 +718,7 @@ func (p *SSHProxy) logOutputLines(sessionID string, lines []string) { Msg("Failed to log terminal event") } - if notice, ok := p.pendingEcho.resolve(utf8.RuneCountInString(line)); ok { + if notice, ok := chState.pendingEcho.resolve(utf8.RuneCountInString(line)); ok { p.logInputCommand(sessionID, notice) } } @@ -751,8 +751,8 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, Msg("Command blocking active for client→server proxy") defer func() { - p.flushInputBuffer(sessionID) - p.flushPendingEcho(sessionID) + p.flushInputBuffer(sessionID, chState) + p.flushPendingEcho(sessionID, chState) }() for { @@ -798,30 +798,30 @@ 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) { // Ctrl+U below wipes the echo, so record the command directly. - p.flushPendingEcho(sessionID) - p.mutex.Lock() - typed := string(p.inputBuffer) - p.inputBuffer = p.inputBuffer[:0] - p.mutex.Unlock() + p.flushPendingEcho(sessionID, chState) + chState.inputMutex.Lock() + typed := string(chState.inputBuffer) + chState.inputBuffer = chState.inputBuffer[:0] + chState.inputMutex.Unlock() if typed != "" { p.logInputCommand(sessionID, textEvent(session.SessionEventInput, channelType, typed)) } // Flush pending output buffer so the echoed command appears before the blocked message - p.flushOutputBuffer(sessionID) + p.flushOutputBuffer(sessionID, chState) // Send error message to client (red text) blockedMsg := "\r\n\033[31m[BLOCKED] Command not permitted\033[0m\r\n" @@ -843,7 +843,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 } @@ -856,7 +856,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 index 78ab7405..3a219095 100644 --- a/packages/pam/handlers/ssh/terminal.go +++ b/packages/pam/handlers/ssh/terminal.go @@ -35,7 +35,7 @@ func (t *terminalTranscript) Feed(data []byte) []string { switch t.parser.Advance(b) { case parser.PrintAction: - t.writeRune(t.parser.Rune()) + t.writeRune(t.parser.Rune(), &lines) case parser.ExecuteAction: t.execute(t.parser.Control(), &lines) case parser.DispatchAction: @@ -74,7 +74,7 @@ func (t *terminalTranscript) execute(control byte, lines *[]string) { t.cursor = max(t.cursor-1, 0) case '\t': for stop := (t.cursor/tabWidth + 1) * tabWidth; t.cursor < stop; { - t.writeRune(' ') + t.writeRune(' ', lines) } } } @@ -118,9 +118,10 @@ func (t *terminalTranscript) commit() string { return line } -func (t *terminalTranscript) writeRune(r rune) { +// 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 { - return + *lines = appendLine(*lines, t.commit()) } t.padTo(t.cursor) if t.cursor < len(t.line) { diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go index 25811dc1..7cdc1898 100644 --- a/packages/pam/handlers/ssh/terminal_test.go +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -49,6 +49,8 @@ func TestTerminalTranscript(t *testing.T) { {"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 { @@ -86,13 +88,13 @@ func (l *recordingLogger) LogSessionEvent(event session.SessionEvent) error { return nil } -func typeLine(p *SSHProxy, channel session.SessionChannelType, text string) { - p.bufferInput([]byte(text), "sid", channel) - p.bufferInput([]byte{0x0D}, "sid", channel) +func typeLine(p *SSHProxy, ch *channelState, channel session.SessionChannelType, text string) { + p.bufferInput([]byte(text), "sid", channel, ch) + p.bufferInput([]byte{0x0D}, "sid", channel, ch) } -func emit(p *SSHProxy, text string) { - p.bufferOutput([]byte(text), "sid", session.SessionChannelShell) +func emit(p *SSHProxy, ch *channelState, text string) { + p.bufferOutput([]byte(text), "sid", session.SessionChannelShell, ch) } func TestSessionRecording(t *testing.T) { @@ -100,42 +102,42 @@ func TestSessionRecording(t *testing.T) { tests := []struct { name string - steps func(p *SSHProxy) + steps func(p *SSHProxy, ch *channelState) want []string }{ { name: "echo replaces the command event", - steps: func(p *SSHProxy) { - emit(p, prompt("~")) - typeLine(p, shell, "ls") - emit(p, "ls\r\nLICENSE go\r\n"+prompt("~")) + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")) + typeLine(p, ch, shell, "ls") + emit(p, ch, "ls\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) { - emit(p, prompt("~/cli")) - typeLine(p, shell, "cd ..\t") - emit(p, "cd ../\r\n") + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~/cli")) + typeLine(p, ch, shell, "cd ..\t") + emit(p, ch, "cd ../\r\n") }, want: []string{"output: user@host:~/cli$ cd ../"}, }, { name: "paste arriving with its newline counts as echoed", - steps: func(p *SSHProxy) { - emit(p, prompt("~")) - p.bufferInput([]byte("cd ../cli\r"), "sid", shell) - emit(p, "cd ../cli\r\n") + 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 without its content", - steps: func(p *SSHProxy) { - emit(p, "[sudo] password for deploy: ") - typeLine(p, shell, "s3cr3t!!") - emit(p, "\r\nroot\r\n") + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, "[sudo] password for deploy: ") + typeLine(p, ch, shell, "s3cr3t!!") + emit(p, ch, "\r\nroot\r\n") }, want: []string{ "output: [sudo] password for deploy:", @@ -145,10 +147,10 @@ func TestSessionRecording(t *testing.T) { }, { name: "multi-line paste counts as echoed", - steps: func(p *SSHProxy) { - emit(p, prompt("~")) - p.bufferInput([]byte("cd cli\nls\n"), "sid", shell) - emit(p, "cd cli\r\n"+prompt("~/cli")+"ls\r\nLICENSE go\r\n") + 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", @@ -158,7 +160,7 @@ func TestSessionRecording(t *testing.T) { }, { name: "exec channel logs its command immediately", - steps: func(p *SSHProxy) { typeLine(p, session.SessionChannelExec, "uname -a") }, + steps: func(p *SSHProxy, ch *channelState) { typeLine(p, ch, session.SessionChannelExec, "uname -a") }, want: []string{"input: uname -a"}, }, } @@ -167,10 +169,11 @@ func TestSessionRecording(t *testing.T) { t.Run(tt.name, func(t *testing.T) { logger := &recordingLogger{} p := NewSSHProxy(SSHProxyConfig{SessionLogger: logger}) + ch := newChannelState() - tt.steps(p) - p.flushOutputBuffer("sid") - p.flushPendingEcho("sid") + tt.steps(p, ch) + p.flushOutputBuffer("sid", ch) + p.flushPendingEcho("sid", ch) got := make([]string, len(logger.events)) for i, e := range logger.events { @@ -188,3 +191,45 @@ func TestSessionRecording(t *testing.T) { }) } } + +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() +} + +// An exec channel must not be able to turn another channel's redaction off. +func TestExecChannelDoesNotLeakShellSecret(t *testing.T) { + logger := &recordingLogger{} + p := NewSSHProxy(SSHProxyConfig{SessionLogger: logger}) + shellCh, execCh := newChannelState(), newChannelState() + + emit(p, shellCh, "Password: ") + p.bufferInput([]byte("topsecret"), "sid", session.SessionChannelShell, shellCh) + p.bufferInput([]byte("id\r"), "sid", session.SessionChannelExec, execCh) + 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 strings.Contains(string(e.Data), "topsecret") { + t.Fatalf("event %q leaked the secret typed on the shell channel", e.Data) + } + } +} From 67dedf49742e02a6d0f393310c6b56e87ac11e66 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 18 Aug 2026 16:43:46 -0400 Subject: [PATCH 3/8] remove echo-off masking --- packages/pam/handlers/ssh/terminal.go | 11 +----- packages/pam/handlers/ssh/terminal_test.go | 39 ++++++++++++---------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go index 3a219095..79f405df 100644 --- a/packages/pam/handlers/ssh/terminal.go +++ b/packages/pam/handlers/ssh/terminal.go @@ -1,11 +1,9 @@ package ssh import ( - "fmt" "strings" "sync" "time" - "unicode/utf8" "github.com/Infisical/infisical-merge/packages/pam/session" "github.com/charmbracelet/x/ansi" @@ -238,18 +236,11 @@ func (e *echoedCommand) takeUnsafe() (session.SessionEvent, bool) { if e.text == "" { return session.SessionEvent{}, false } - // Echo off means a secret prompt; recording the keystrokes would log the password. - count := utf8.RuneCountInString(e.text) - notice := fmt.Sprintf("[no echo] %d characters submitted", count) - if count == 1 { - notice = "[no echo] 1 character submitted" - } - event := session.SessionEvent{ Timestamp: e.timestamp, EventType: session.SessionEventInput, ChannelType: e.channel, - Data: []byte(notice), + Data: []byte(e.text), Rendered: true, } e.text = "" diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go index 7cdc1898..3e96a384 100644 --- a/packages/pam/handlers/ssh/terminal_test.go +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -133,17 +133,13 @@ func TestSessionRecording(t *testing.T) { want: []string{"output: user@host:~$ cd ../cli"}, }, { - name: "unechoed input is recorded without its content", + name: "unechoed input is still recorded", steps: func(p *SSHProxy, ch *channelState) { - emit(p, ch, "[sudo] password for deploy: ") - typeLine(p, ch, shell, "s3cr3t!!") + emit(p, ch, prompt("~")) + typeLine(p, ch, shell, "whoami") emit(p, ch, "\r\nroot\r\n") }, - want: []string{ - "output: [sudo] password for deploy:", - "input: [no echo] 8 characters submitted", - "output: root", - }, + want: []string{"output: user@host:~$", "input: whoami", "output: root"}, }, { name: "multi-line paste counts as echoed", @@ -180,9 +176,6 @@ func TestSessionRecording(t *testing.T) { if !e.Rendered || e.Timestamp.IsZero() { t.Errorf("event %d (%q) lacks the rendered flag or a timestamp", i, e.Data) } - if strings.Contains(string(e.Data), "s3cr3t") { - t.Errorf("event %d recorded the typed secret", i) - } got[i] = string(e.EventType) + ": " + string(e.Data) } if !slices.Equal(got, tt.want) { @@ -213,23 +206,33 @@ func TestConcurrentChannelsKeepSeparateTranscripts(t *testing.T) { wg.Wait() } -// An exec channel must not be able to turn another channel's redaction off. -func TestExecChannelDoesNotLeakShellSecret(t *testing.T) { +// 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, "Password: ") - p.bufferInput([]byte("topsecret"), "sid", session.SessionChannelShell, shellCh) - p.bufferInput([]byte("id\r"), "sid", session.SessionChannelExec, execCh) + 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 strings.Contains(string(e.Data), "topsecret") { - t.Fatalf("event %q leaked the secret typed on the shell channel", e.Data) + 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) } } } From ae037a2d829f262d7c405d07361990467b3ca819 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 18 Aug 2026 16:52:52 -0400 Subject: [PATCH 4/8] address veria comment --- packages/pam/handlers/ssh/terminal.go | 4 ++-- packages/pam/handlers/ssh/terminal_test.go | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go index 79f405df..f3ea6393 100644 --- a/packages/pam/handlers/ssh/terminal.go +++ b/packages/pam/handlers/ssh/terminal.go @@ -150,10 +150,10 @@ func (t *terminalTranscript) deleteChars(n int) { } func (t *terminalTranscript) insertBlanks(n int) { - if n = min(n, maxLineRunes-t.cursor); n <= 0 { + t.padTo(t.cursor) + if n = min(n, maxLineRunes-len(t.line)); n <= 0 { return } - t.padTo(t.cursor) 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) diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go index 3e96a384..b046a065 100644 --- a/packages/pam/handlers/ssh/terminal_test.go +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -67,8 +67,14 @@ func TestTranscriptBoundsPathologicalInput(t *testing.T) { tr.Feed([]byte(strings.Repeat("x", 100_000))) tr.Feed([]byte(esc + "[999999999999C" + esc + "[999999999999@")) - if n := tr.PendingLen(); n > maxLineRunes { - t.Errorf("pending line grew to %d runes, want at most %d", n, maxLineRunes) + // 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) } } From 67115ddea36b48aae0333ec8fefaa0a1f753afce Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 19 Aug 2026 19:38:37 -0400 Subject: [PATCH 5/8] tidy --- e2e/go.mod | 8 +++++--- e2e/go.sum | 15 ++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/e2e/go.mod b/e2e/go.mod index ed358a56..0be54a4e 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 @@ -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..d41330c3 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= @@ -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= From d654b35834398cfd24a48a6f1e34dea786c712fa Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 20 Aug 2026 13:38:21 -0400 Subject: [PATCH 6/8] hide secret prompt inputs + general fixes and improvements --- packages/pam/handlers/ssh/proxy.go | 106 ++++++++++------ packages/pam/handlers/ssh/terminal.go | 56 +++++++-- packages/pam/handlers/ssh/terminal_test.go | 137 ++++++++++++++++++++- 3 files changed, 246 insertions(+), 53 deletions(-) diff --git a/packages/pam/handlers/ssh/proxy.go b/packages/pam/handlers/ssh/proxy.go index 2c837d75..1238785a 100644 --- a/packages/pam/handlers/ssh/proxy.go +++ b/packages/pam/handlers/ssh/proxy.go @@ -10,7 +10,6 @@ import ( "sync" "sync/atomic" "time" - "unicode/utf8" "github.com/Infisical/infisical-merge/packages/pam/session" "github.com/Infisical/infisical-merge/packages/util" @@ -37,9 +36,8 @@ type SSHProxy struct { config SSHProxyConfig } -// channelState holds per-channel state. A client can open channels concurrently, -// so recording state lives here: sharing it would interleave two channels into -// one transcript and let one channel's type decide another's redaction. +// 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) @@ -52,11 +50,18 @@ type channelState struct { 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 + echoPrompt string // Tail of that line, matched against secret prompts 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 + outputPending atomic.Pointer[screenLine] // The line still on screen, read without a lock +} + +// screenLine is a snapshot of the line on screen, published for the input goroutine. +type screenLine struct { + runes int + text string } func newChannelState() *channelState { @@ -66,7 +71,7 @@ func newChannelState() *channelState { } } -func textEvent(eventType session.SessionEventType, channelType session.SessionChannelType, text string) session.SessionEvent { +func (c *channelState) textEvent(eventType session.SessionEventType, channelType session.SessionChannelType, text string) session.SessionEvent { return session.SessionEvent{ Timestamp: time.Now(), EventType: eventType, @@ -383,7 +388,7 @@ func (p *SSHProxy) handleChannelRequests(requests <-chan *ssh.Request, targetCha Msg("Blocked SSH exec command") // Log the blocked exec to session recording - blockedEvent := textEvent(session.SessionEventInput, session.SessionChannelExec, + 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") @@ -434,7 +439,7 @@ func (p *SSHProxy) handleChannelRequests(requests <-chan *ssh.Request, targetCha logMessage = fmt.Sprintf("$ %s", command) } - event := textEvent(session.SessionEventInput, channelType, logMessage) + event := chState.textEvent(session.SessionEventInput, channelType, logMessage) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -470,7 +475,7 @@ func (p *SSHProxy) handleChannelRequests(requests <-chan *ssh.Request, targetCha chState.sftpParser = NewSFTPParser() chState.mutex.Unlock() - event := textEvent(session.SessionEventInput, session.SessionChannelSFTP, "File transfer session started") + 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). @@ -552,7 +557,7 @@ func (p *SSHProxy) proxyData(src io.Reader, dst io.Writer, direction string, ses for _, op := range operations { // Log each SFTP operation logMsg := FormatOperation(op) - event := textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) + event := chState.textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -603,6 +608,7 @@ func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType sessio defer chState.inputMutex.Unlock() chState.inputChannelType = channelType + typedThisRead := false for _, b := range data { if chState.inputScanner.consumed(b) { @@ -628,18 +634,22 @@ func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType sessio chState.inputBuffer = chState.inputBuffer[:len(chState.inputBuffer)-1] } case 0x0D, 0x0A: // CR or LF - flush the buffer - p.flushInputBufferUnsafe(sessionID, chState) + p.flushInputBufferUnsafe(sessionID, chState, typedThisRead) default: // Only buffer printable characters and tab if b >= 0x20 || b == 0x09 { if len(chState.inputBuffer) == 0 { - chState.echoBaseline = int(chState.outputPending.Load()) + chState.echoBaseline, chState.echoPrompt = 0, "" + if screen := chState.outputPending.Load(); screen != nil { + chState.echoBaseline, chState.echoPrompt = screen.runes, screen.text + } } chState.inputBuffer = append(chState.inputBuffer, b) + typedThisRead = true } // Safety: flush if buffer gets too large if len(chState.inputBuffer) >= 1024 { - p.flushInputBufferUnsafe(sessionID, chState) + p.flushInputBufferUnsafe(sessionID, chState, typedThisRead) } } } @@ -649,24 +659,45 @@ func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType sessio func (p *SSHProxy) flushInputBuffer(sessionID string, chState *channelState) { chState.inputMutex.Lock() defer chState.inputMutex.Unlock() - p.flushInputBufferUnsafe(sessionID, chState) + p.flushInputBufferUnsafe(sessionID, chState, false) } -// flushInputBufferUnsafe flushes the input buffer without locking (caller must hold lock) -func (p *SSHProxy) flushInputBufferUnsafe(sessionID string, chState *channelState) { +// 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 } command := string(chState.inputBuffer) chState.inputBuffer = chState.inputBuffer[:0] + channel := chState.inputChannelType - if chState.inputChannelType != session.SessionChannelShell { - p.logInputCommand(sessionID, textEvent(session.SessionEventInput, chState.inputChannelType, command)) + if channel != session.SessionChannelShell { + p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, command)) return } - chState.pendingEcho.hold(command, chState.inputChannelType, chState.echoBaseline) + onScreen := 0 + if screen := chState.outputPending.Load(); screen != nil { + onScreen = screen.runes + } + + // 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 isSecretPrompt(chState.echoPrompt): + p.commitPendingLine(sessionID, chState, chState.echoBaseline) + p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, redactedPromptInput)) + 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.commitPendingLine(sessionID, chState, -1) + p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, command)) + } } // flushPendingEcho records a held command that the shell never echoed back @@ -695,22 +726,28 @@ func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType sessi // 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())) + runes, text := chState.outputParser.pendingLine() + chState.outputPending.Store(&screenLine{runes: runes, text: text}) } // flushOutputBuffer commits a partially rendered line func (p *SSHProxy) flushOutputBuffer(sessionID string, chState *channelState) { + p.commitPendingLine(sessionID, chState, -1) +} + +// commitPendingLine commits the line still on screen, cut back to trimTo runes when +// a secret prompt needs whatever was echoed after it removed. +func (p *SSHProxy) commitPendingLine(sessionID string, chState *channelState, trimTo int) { chState.outputMutex.Lock() defer chState.outputMutex.Unlock() - p.logOutputLines(sessionID, chState, chState.outputParser.Flush()) - chState.outputPending.Store(0) + p.logOutputLines(sessionID, chState, chState.outputParser.FlushAt(trimTo)) + chState.outputPending.Store(&screenLine{}) } // 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 := textEvent(session.SessionEventOutput, chState.outputChannelType, line) - + event := chState.textEvent(session.SessionEventOutput, chState.outputChannelType, line) if err := p.config.SessionLogger.LogSessionEvent(event); err != nil { log.Error().Err(err). Str("sessionID", sessionID). @@ -718,8 +755,8 @@ func (p *SSHProxy) logOutputLines(sessionID string, chState *channelState, lines Msg("Failed to log terminal event") } - if notice, ok := chState.pendingEcho.resolve(utf8.RuneCountInString(line)); ok { - p.logInputCommand(sessionID, notice) + if unechoed, ok := chState.pendingEcho.settle(line); ok { + p.logInputCommand(sessionID, unechoed) } } } @@ -774,7 +811,7 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, operations := sftpParser.Parse(buf[:n]) for _, op := range operations { logMsg := FormatOperation(op) - event := textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) + event := chState.textEvent(session.SessionEventInput, session.SessionChannelSFTP, logMsg) if logErr := p.config.SessionLogger.LogSessionEvent(event); logErr != nil { log.Error().Err(logErr). Str("sessionID", sessionID). @@ -810,17 +847,8 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, chState.inputMutex.Unlock() if p.matchBlockedCommand(command) { - // Ctrl+U below wipes the echo, so record the command directly. - p.flushPendingEcho(sessionID, chState) - chState.inputMutex.Lock() - typed := string(chState.inputBuffer) - chState.inputBuffer = chState.inputBuffer[:0] - chState.inputMutex.Unlock() - if typed != "" { - p.logInputCommand(sessionID, textEvent(session.SessionEventInput, channelType, typed)) - } - - // Flush pending output buffer so the echoed command appears before the blocked message + // 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) @@ -828,7 +856,7 @@ func (p *SSHProxy) proxyClientToServerWithBlocking(src io.Reader, dst io.Writer, clientWriter.Write([]byte(blockedMsg)) // Log the blocked message as output so it appears in session replay - blockedEvent := textEvent(session.SessionEventOutput, channelType, "[BLOCKED] Command not permitted") + 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") } diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go index f3ea6393..4fb294c8 100644 --- a/packages/pam/handlers/ssh/terminal.go +++ b/packages/pam/handlers/ssh/terminal.go @@ -1,9 +1,11 @@ package ssh import ( + "regexp" "strings" "sync" "time" + "unicode/utf8" "github.com/Infisical/infisical-merge/packages/pam/session" "github.com/charmbracelet/x/ansi" @@ -11,8 +13,9 @@ import ( ) const ( - maxLineRunes = 8192 - tabWidth = 8 + maxLineRunes = 8192 + tabWidth = 8 + promptTailRunes = 256 ) type terminalTranscript struct { @@ -46,15 +49,26 @@ func (t *terminalTranscript) Feed(data []byte) []string { } func (t *terminalTranscript) Flush() []string { + return t.FlushAt(-1) +} + +// FlushAt cuts the pending line back to n runes before committing it, dropping a +// secret the shell echoed after the prompt. n < 0 keeps it whole. +func (t *terminalTranscript) FlushAt(n int) []string { + if n >= 0 && n < len(t.line) { + t.line = t.line[:n] + } return appendLine(nil, t.commit()) } -func (t *terminalTranscript) PendingLen() int { +// pendingLine returns the length of the line on screen and its tail, capped so a +// long line stays cheap to snapshot. +func (t *terminalTranscript) pendingLine() (int, string) { n := len(t.line) for n > 0 && t.line[n-1] == ' ' { n-- } - return n + return n, string(t.line[max(n-promptTailRunes, 0):n]) } func isCSIState(state parser.State) bool { @@ -209,17 +223,16 @@ func (e *echoedCommand) hold(text string, channel session.SessionChannelType, ba e.text, e.timestamp, e.channel, e.baseline = text, time.Now(), channel, baseline } -// resolve settles a held command against the first line committed after it. A -// line longer than what was on screen when typing began was echoed, so the -// echoed line stands in for the command; otherwise echo was off. -func (e *echoedCommand) resolve(lineRunes int) (session.SessionEvent, bool) { +// 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 lineRunes > e.baseline { + if utf8.RuneCountInString(line) > e.baseline { e.text = "" return session.SessionEvent{}, false } @@ -246,3 +259,28 @@ func (e *echoedCommand) takeUnsafe() (session.SessionEvent, bool) { e.text = "" return event, true } + +const redactedPromptInput = "[redacted] secret prompt input" + +// Requiring a terminator right after the keyword keeps ordinary output that merely +// mentions one of these words from matching. +var secretPromptPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)pass(word|phrase|code)[^:]*:$`), + regexp.MustCompile(`(?i)\bpin\b[^:]*:$`), + regexp.MustCompile(`(?i)(verification|authentication|security|access) code[^:]*:$`), + regexp.MustCompile(`(?i)\b(otp|2fa|mfa)\b[^:]*:$`), + regexp.MustCompile(`(?i)(secret|token|credential)s?[^:]*:$`), + regexp.MustCompile(`(?i)enter[^:]*\bkey\b[^:]*:$`), +} + +// isSecretPrompt reports whether the line on screen is asking for a secret, which is +// what distinguishes a password from a command. Echo state does not. +func isSecretPrompt(line string) bool { + line = strings.TrimRight(line, " ") + for _, pattern := range secretPromptPatterns { + if pattern.MatchString(line) { + return true + } + } + return false +} diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go index b046a065..78d9684f 100644 --- a/packages/pam/handlers/ssh/terminal_test.go +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -1,6 +1,8 @@ package ssh import ( + "io" + "regexp" "slices" "strings" "sync" @@ -94,11 +96,21 @@ func (l *recordingLogger) LogSessionEvent(event session.SessionEvent) error { 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) } @@ -116,7 +128,7 @@ func TestSessionRecording(t *testing.T) { steps: func(p *SSHProxy, ch *channelState) { emit(p, ch, prompt("~")) typeLine(p, ch, shell, "ls") - emit(p, ch, "ls\r\nLICENSE go\r\n"+prompt("~")) + emit(p, ch, "\r\nLICENSE go\r\n"+prompt("~")) }, want: []string{"output: user@host:~$ ls", "output: LICENSE go", "output: user@host:~$"}, }, @@ -124,8 +136,8 @@ func TestSessionRecording(t *testing.T) { name: "tab completion counts as echoed", steps: func(p *SSHProxy, ch *channelState) { emit(p, ch, prompt("~/cli")) - typeLine(p, ch, shell, "cd ..\t") - emit(p, ch, "cd ../\r\n") + typeLine(p, ch, shell, "cd ..") + emit(p, ch, "/\r\n") }, want: []string{"output: user@host:~/cli$ cd ../"}, }, @@ -139,10 +151,10 @@ func TestSessionRecording(t *testing.T) { want: []string{"output: user@host:~$ cd ../cli"}, }, { - name: "unechoed input is still recorded", + name: "unechoed input is recorded when it is typed", steps: func(p *SSHProxy, ch *channelState) { emit(p, ch, prompt("~")) - typeLine(p, ch, shell, "whoami") + typeSilently(p, ch, "whoami") emit(p, ch, "\r\nroot\r\n") }, want: []string{"output: user@host:~$", "input: whoami", "output: root"}, @@ -160,6 +172,33 @@ func TestSessionRecording(t *testing.T) { "output: LICENSE go", }, }, + { + name: "input at a secret prompt is redacted", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, prompt("~")+"sudo -k id\r\n[sudo] password for deploy: ") + typeSilently(p, ch, "hunter2") + emit(p, ch, "\r\nuid=0(root)\r\n") + }, + want: []string{ + "output: user@host:~$ sudo -k id", + "output: [sudo] password for deploy:", + "input: [redacted] secret prompt input", + "output: uid=0(root)", + }, + }, + { + name: "secret echoed in plaintext is trimmed from the transcript", + steps: func(p *SSHProxy, ch *channelState) { + emit(p, ch, "Enter token: ") + typeLine(p, ch, shell, "thisistopsecret") + emit(p, ch, "\r\nok\r\n") + }, + want: []string{ + "output: Enter token:", + "input: [redacted] secret prompt input", + "output: ok", + }, + }, { name: "exec channel logs its command immediately", steps: func(p *SSHProxy, ch *channelState) { typeLine(p, ch, session.SessionChannelExec, "uname -a") }, @@ -242,3 +281,91 @@ func TestChannelsRecordIndependently(t *testing.T) { } } } + +func TestIsSecretPrompt(t *testing.T) { + secret := []string{ + "[sudo] password for deploy:", + "Password:", + "Password: ", + "root@host's password:", + "Enter passphrase for key '/root/.ssh/id_rsa':", + "Enter PIN:", + "Verification code:", + "Enter your OTP:", + "Vault token:", + "New UNIX password:", + } + ordinary := []string{ + "user@host:~$", + "root@password-vault:~#", + "root@host:/etc/passwd#", + "mysql>", + "Available tokens: 3", + "Reading package lists...", + "total 0", + "", + } + + for _, line := range secret { + if !isSecretPrompt(line) { + t.Errorf("isSecretPrompt(%q) = false, want true", line) + } + } + for _, line := range ordinary { + if isSecretPrompt(line) { + t.Errorf("isSecretPrompt(%q) = true, want false", line) + } + } +} + +// 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) + } +} From 17c9ddfa2df273c88ad29ad3bf15d7ccb3f787ba Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 20 Aug 2026 15:59:05 -0400 Subject: [PATCH 7/8] remove password hiding --- packages/pam/handlers/ssh/proxy.go | 37 +++---------- packages/pam/handlers/ssh/terminal.go | 46 ++-------------- packages/pam/handlers/ssh/terminal_test.go | 61 +++------------------- 3 files changed, 17 insertions(+), 127 deletions(-) diff --git a/packages/pam/handlers/ssh/proxy.go b/packages/pam/handlers/ssh/proxy.go index 1238785a..4ead932f 100644 --- a/packages/pam/handlers/ssh/proxy.go +++ b/packages/pam/handlers/ssh/proxy.go @@ -50,18 +50,11 @@ type channelState struct { 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 - echoPrompt string // Tail of that line, matched against secret prompts outputMutex sync.Mutex outputParser *terminalTranscript // Renders the output stream into displayed lines outputChannelType session.SessionChannelType // Channel type for buffered output - outputPending atomic.Pointer[screenLine] // The line still on screen, read without a lock -} - -// screenLine is a snapshot of the line on screen, published for the input goroutine. -type screenLine struct { - runes int - text string + outputPending atomic.Int64 // Rendered length of the line still on screen } func newChannelState() *channelState { @@ -639,10 +632,7 @@ func (p *SSHProxy) bufferInput(data []byte, sessionID string, channelType sessio // Only buffer printable characters and tab if b >= 0x20 || b == 0x09 { if len(chState.inputBuffer) == 0 { - chState.echoBaseline, chState.echoPrompt = 0, "" - if screen := chState.outputPending.Load(); screen != nil { - chState.echoBaseline, chState.echoPrompt = screen.runes, screen.text - } + chState.echoBaseline = int(chState.outputPending.Load()) } chState.inputBuffer = append(chState.inputBuffer, b) typedThisRead = true @@ -678,24 +668,18 @@ func (p *SSHProxy) flushInputBufferUnsafe(sessionID string, chState *channelStat return } - onScreen := 0 - if screen := chState.outputPending.Load(); screen != nil { - onScreen = screen.runes - } + 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 isSecretPrompt(chState.echoPrompt): - p.commitPendingLine(sessionID, chState, chState.echoBaseline) - p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, redactedPromptInput)) 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.commitPendingLine(sessionID, chState, -1) + p.flushOutputBuffer(sessionID, chState) p.logInputCommand(sessionID, chState.textEvent(session.SessionEventInput, channel, command)) } } @@ -726,22 +710,15 @@ func (p *SSHProxy) bufferOutput(data []byte, sessionID string, channelType sessi // 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. - runes, text := chState.outputParser.pendingLine() - chState.outputPending.Store(&screenLine{runes: runes, text: text}) + chState.outputPending.Store(int64(chState.outputParser.PendingLen())) } // flushOutputBuffer commits a partially rendered line func (p *SSHProxy) flushOutputBuffer(sessionID string, chState *channelState) { - p.commitPendingLine(sessionID, chState, -1) -} - -// commitPendingLine commits the line still on screen, cut back to trimTo runes when -// a secret prompt needs whatever was echoed after it removed. -func (p *SSHProxy) commitPendingLine(sessionID string, chState *channelState, trimTo int) { chState.outputMutex.Lock() defer chState.outputMutex.Unlock() - p.logOutputLines(sessionID, chState, chState.outputParser.FlushAt(trimTo)) - chState.outputPending.Store(&screenLine{}) + p.logOutputLines(sessionID, chState, chState.outputParser.Flush()) + chState.outputPending.Store(0) } // logOutputLines writes one output event per rendered line (caller must hold outputMutex) diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go index 4fb294c8..b11ca811 100644 --- a/packages/pam/handlers/ssh/terminal.go +++ b/packages/pam/handlers/ssh/terminal.go @@ -1,7 +1,6 @@ package ssh import ( - "regexp" "strings" "sync" "time" @@ -13,9 +12,8 @@ import ( ) const ( - maxLineRunes = 8192 - tabWidth = 8 - promptTailRunes = 256 + maxLineRunes = 8192 + tabWidth = 8 ) type terminalTranscript struct { @@ -49,26 +47,15 @@ func (t *terminalTranscript) Feed(data []byte) []string { } func (t *terminalTranscript) Flush() []string { - return t.FlushAt(-1) -} - -// FlushAt cuts the pending line back to n runes before committing it, dropping a -// secret the shell echoed after the prompt. n < 0 keeps it whole. -func (t *terminalTranscript) FlushAt(n int) []string { - if n >= 0 && n < len(t.line) { - t.line = t.line[:n] - } return appendLine(nil, t.commit()) } -// pendingLine returns the length of the line on screen and its tail, capped so a -// long line stays cheap to snapshot. -func (t *terminalTranscript) pendingLine() (int, string) { +func (t *terminalTranscript) PendingLen() int { n := len(t.line) for n > 0 && t.line[n-1] == ' ' { n-- } - return n, string(t.line[max(n-promptTailRunes, 0):n]) + return n } func isCSIState(state parser.State) bool { @@ -259,28 +246,3 @@ func (e *echoedCommand) takeUnsafe() (session.SessionEvent, bool) { e.text = "" return event, true } - -const redactedPromptInput = "[redacted] secret prompt input" - -// Requiring a terminator right after the keyword keeps ordinary output that merely -// mentions one of these words from matching. -var secretPromptPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)pass(word|phrase|code)[^:]*:$`), - regexp.MustCompile(`(?i)\bpin\b[^:]*:$`), - regexp.MustCompile(`(?i)(verification|authentication|security|access) code[^:]*:$`), - regexp.MustCompile(`(?i)\b(otp|2fa|mfa)\b[^:]*:$`), - regexp.MustCompile(`(?i)(secret|token|credential)s?[^:]*:$`), - regexp.MustCompile(`(?i)enter[^:]*\bkey\b[^:]*:$`), -} - -// isSecretPrompt reports whether the line on screen is asking for a secret, which is -// what distinguishes a password from a command. Echo state does not. -func isSecretPrompt(line string) bool { - line = strings.TrimRight(line, " ") - for _, pattern := range secretPromptPatterns { - if pattern.MatchString(line) { - return true - } - } - return false -} diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go index 78d9684f..105252df 100644 --- a/packages/pam/handlers/ssh/terminal_test.go +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -173,29 +173,16 @@ func TestSessionRecording(t *testing.T) { }, }, { - name: "input at a secret prompt is redacted", + name: "input at a prompt that does not echo is recorded", steps: func(p *SSHProxy, ch *channelState) { - emit(p, ch, prompt("~")+"sudo -k id\r\n[sudo] password for deploy: ") - typeSilently(p, ch, "hunter2") - emit(p, ch, "\r\nuid=0(root)\r\n") - }, - want: []string{ - "output: user@host:~$ sudo -k id", - "output: [sudo] password for deploy:", - "input: [redacted] secret prompt input", - "output: uid=0(root)", - }, - }, - { - name: "secret echoed in plaintext is trimmed from the transcript", - steps: func(p *SSHProxy, ch *channelState) { - emit(p, ch, "Enter token: ") - typeLine(p, ch, shell, "thisistopsecret") + 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: Enter token:", - "input: [redacted] secret prompt input", + "output: user@host:~$ read -s -p \"Enter name: \" x", + "output: Enter name:", + "input: operator", "output: ok", }, }, @@ -282,42 +269,6 @@ func TestChannelsRecordIndependently(t *testing.T) { } } -func TestIsSecretPrompt(t *testing.T) { - secret := []string{ - "[sudo] password for deploy:", - "Password:", - "Password: ", - "root@host's password:", - "Enter passphrase for key '/root/.ssh/id_rsa':", - "Enter PIN:", - "Verification code:", - "Enter your OTP:", - "Vault token:", - "New UNIX password:", - } - ordinary := []string{ - "user@host:~$", - "root@password-vault:~#", - "root@host:/etc/passwd#", - "mysql>", - "Available tokens: 3", - "Reading package lists...", - "total 0", - "", - } - - for _, line := range secret { - if !isSecretPrompt(line) { - t.Errorf("isSecretPrompt(%q) = false, want true", line) - } - } - for _, line := range ordinary { - if isSecretPrompt(line) { - t.Errorf("isSecretPrompt(%q) = true, want false", line) - } - } -} - // 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 { From 32d0a8214ee94864060b33fba4673bbac2fd6716 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 20 Aug 2026 16:08:21 -0400 Subject: [PATCH 8/8] fix review and patch vuln --- e2e/go.mod | 4 ++-- e2e/go.sum | 8 ++++---- go.mod | 4 ++-- go.sum | 8 ++++---- packages/pam/handlers/ssh/terminal.go | 22 ++++++++++++++++++++++ packages/pam/handlers/ssh/terminal_test.go | 17 +++++++++++++++++ 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/e2e/go.mod b/e2e/go.mod index 0be54a4e..d69523b4 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -253,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 diff --git a/e2e/go.sum b/e2e/go.sum index d41330c3..f87948b2 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -863,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= diff --git a/go.mod b/go.mod index 99df801f..a51f4bb0 100644 --- a/go.mod +++ b/go.mod @@ -184,9 +184,9 @@ 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/shopspring/decimal v1.4.0 // indirect github.com/sony/gobreaker v0.5.0 // indirect diff --git a/go.sum b/go.sum index 8aece601..9fe5eb9e 100644 --- a/go.sum +++ b/go.sum @@ -576,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= diff --git a/packages/pam/handlers/ssh/terminal.go b/packages/pam/handlers/ssh/terminal.go index b11ca811..8bcd7bee 100644 --- a/packages/pam/handlers/ssh/terminal.go +++ b/packages/pam/handlers/ssh/terminal.go @@ -181,6 +181,14 @@ func newInputSequenceFilter() *inputSequenceFilter { } 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 @@ -189,6 +197,20 @@ func (f *inputSequenceFilter) consumed(b byte) bool { } } +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. diff --git a/packages/pam/handlers/ssh/terminal_test.go b/packages/pam/handlers/ssh/terminal_test.go index 105252df..5644c58e 100644 --- a/packages/pam/handlers/ssh/terminal_test.go +++ b/packages/pam/handlers/ssh/terminal_test.go @@ -320,3 +320,20 @@ func TestBlockedCommandIsNotDuplicated(t *testing.T) { 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") + } + } +}