feat: capture SSH exec commands (PLAN.md 4.4)
Bots often send commands via `ssh user@host <command>` (exec request) rather than requesting an interactive shell. These were previously rejected silently. Now exec commands are captured, stored on the session record, and displayed in the web UI session detail page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,16 @@ func (m *MemoryStore) UpdateHumanScore(_ context.Context, sessionID string, scor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) SetExecCommand(_ context.Context, sessionID string, command string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if s, ok := m.Sessions[sessionID]; ok {
|
||||
s.ExecCommand = &command
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, output string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
1
internal/storage/migrations/004_add_exec_command.sql
Normal file
1
internal/storage/migrations/004_add_exec_command.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE sessions ADD COLUMN exec_command TEXT;
|
||||
@@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) {
|
||||
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||
t.Fatalf("query version: %v", err)
|
||||
}
|
||||
if version != 3 {
|
||||
t.Errorf("version = %d, want 3", version)
|
||||
if version != 4 {
|
||||
t.Errorf("version = %d, want 4", version)
|
||||
}
|
||||
|
||||
// Verify tables exist by inserting into them.
|
||||
@@ -64,8 +64,8 @@ func TestMigrateIdempotent(t *testing.T) {
|
||||
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||
t.Fatalf("query version: %v", err)
|
||||
}
|
||||
if version != 3 {
|
||||
t.Errorf("version = %d after double migrate, want 3", version)
|
||||
if version != 4 {
|
||||
t.Errorf("version = %d after double migrate, want 4", version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, sc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SetExecCommand(ctx context.Context, sessionID string, command string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE sessions SET exec_command = ? WHERE id = ?`,
|
||||
command, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting exec command: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
@@ -100,12 +110,13 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
||||
var connectedAt string
|
||||
var disconnectedAt sql.NullString
|
||||
var humanScore sql.NullFloat64
|
||||
var execCommand sql.NullString
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score
|
||||
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command
|
||||
FROM sessions WHERE id = ?`, sessionID).Scan(
|
||||
&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName,
|
||||
&connectedAt, &disconnectedAt, &humanScore,
|
||||
&connectedAt, &disconnectedAt, &humanScore, &execCommand,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -122,6 +133,9 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
||||
if humanScore.Valid {
|
||||
sess.HumanScore = &humanScore.Float64
|
||||
}
|
||||
if execCommand.Valid {
|
||||
sess.ExecCommand = &execCommand.String
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
@@ -368,7 +382,7 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) (
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
|
||||
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score FROM sessions`
|
||||
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions`
|
||||
if activeOnly {
|
||||
query += ` WHERE disconnected_at IS NULL`
|
||||
}
|
||||
@@ -386,7 +400,8 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
||||
var connectedAt string
|
||||
var disconnectedAt sql.NullString
|
||||
var humanScore sql.NullFloat64
|
||||
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil {
|
||||
var execCommand sql.NullString
|
||||
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand); err != nil {
|
||||
return nil, fmt.Errorf("scanning session: %w", err)
|
||||
}
|
||||
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||
@@ -397,6 +412,9 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
||||
if humanScore.Valid {
|
||||
s.HumanScore = &humanScore.Float64
|
||||
}
|
||||
if execCommand.Valid {
|
||||
s.ExecCommand = &execCommand.String
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
|
||||
@@ -27,6 +27,7 @@ type Session struct {
|
||||
ConnectedAt time.Time
|
||||
DisconnectedAt *time.Time
|
||||
HumanScore *float64
|
||||
ExecCommand *string
|
||||
}
|
||||
|
||||
// SessionLog represents a single log entry for a session.
|
||||
@@ -76,6 +77,9 @@ type Store interface {
|
||||
// UpdateHumanScore sets the human detection score for a session.
|
||||
UpdateHumanScore(ctx context.Context, sessionID string, score float64) error
|
||||
|
||||
// SetExecCommand sets the exec command for a session.
|
||||
SetExecCommand(ctx context.Context, sessionID string, command string) error
|
||||
|
||||
// AppendSessionLog adds a log entry to a session.
|
||||
AppendSessionLog(ctx context.Context, sessionID, input, output string) error
|
||||
|
||||
|
||||
@@ -361,6 +361,69 @@ func TestCloseActiveSessions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetExecCommand(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("set and retrieve", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
// Initially nil.
|
||||
s, err := store.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if s.ExecCommand != nil {
|
||||
t.Errorf("expected nil ExecCommand, got %q", *s.ExecCommand)
|
||||
}
|
||||
|
||||
// Set exec command.
|
||||
if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil {
|
||||
t.Fatalf("SetExecCommand: %v", err)
|
||||
}
|
||||
|
||||
s, err = store.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if s.ExecCommand == nil {
|
||||
t.Fatal("expected non-nil ExecCommand")
|
||||
}
|
||||
if *s.ExecCommand != "uname -a" {
|
||||
t.Errorf("ExecCommand = %q, want %q", *s.ExecCommand, "uname -a")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appears in recent sessions", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if err := store.SetExecCommand(ctx, id, "id"); err != nil {
|
||||
t.Fatalf("SetExecCommand: %v", err)
|
||||
}
|
||||
|
||||
sessions, err := store.GetRecentSessions(ctx, 10, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentSessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(sessions))
|
||||
}
|
||||
if sessions[0].ExecCommand == nil || *sessions[0].ExecCommand != "id" {
|
||||
t.Errorf("ExecCommand = %v, want \"id\"", sessions[0].ExecCommand)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecentSessions(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user