main
Raw Download raw file
  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"mysh/pkg/mythic"
  7	"os"
  8	"sort"
  9	"strconv"
 10	"strings"
 11	"time"
 12
 13	"github.com/jedib0t/go-pretty/v6/table"
 14	"github.com/spf13/cobra"
 15)
 16
 17var (
 18	sortByID          bool
 19	sortByType        bool
 20	sortByHost        bool
 21	sortByUser        bool
 22	sortByProcess     bool
 23	sortByLast        bool
 24	sortByDescription bool
 25	sortReverse       bool
 26	showCommands      bool
 27)
 28
 29var callbacksCmd = &cobra.Command{
 30	Use:     "callbacks [callback_id]",
 31	Aliases: []string{"cb", "callback"},
 32	Short:   "List active callbacks or show detailed callback information",
 33	Long:    "Display all active callbacks from the Mythic server, or show detailed information about a specific callback including available commands.",
 34	Args:    cobra.MaximumNArgs(1),
 35	RunE:    runCallbacks,
 36}
 37
 38func init() {
 39	rootCmd.AddCommand(callbacksCmd)
 40
 41	// Sort flags - mutually exclusive
 42	callbacksCmd.Flags().BoolVar(&sortByID, "id", false, "Sort by callback ID (default)")
 43	callbacksCmd.Flags().BoolVar(&sortByType, "type", false, "Sort by agent type")
 44	callbacksCmd.Flags().BoolVar(&sortByHost, "host", false, "Sort by hostname")
 45	callbacksCmd.Flags().BoolVar(&sortByUser, "user", false, "Sort by username")
 46	callbacksCmd.Flags().BoolVar(&sortByProcess, "process", false, "Sort by process name")
 47	callbacksCmd.Flags().BoolVar(&sortByLast, "last", false, "Sort by last checkin time")
 48	callbacksCmd.Flags().BoolVar(&sortByDescription, "description", false, "Sort by description")
 49
 50	// Reverse sort flag
 51	callbacksCmd.Flags().BoolVar(&sortReverse, "rev", false, "Reverse sort order")
 52
 53	// Commands flag for detailed view
 54	callbacksCmd.Flags().BoolVar(&showCommands, "commands", false, "Show available commands in detailed callback view")
 55}
 56
 57// formatTimeSince formats a timestamp into a human-readable "time ago" format
 58// like "0s ago", "1m 30s ago", "2h 15m ago", etc.
 59func formatTimeSince(timestampStr string) string {
 60	if timestampStr == "" {
 61		return "unknown"
 62	}
 63
 64	// Parse the timestamp - try common formats
 65	var parsedTime time.Time
 66	var err error
 67
 68	// Try RFC3339 format first (most common)
 69	parsedTime, err = time.Parse(time.RFC3339, timestampStr)
 70	if err != nil {
 71		// Try RFC3339Nano format
 72		parsedTime, err = time.Parse(time.RFC3339Nano, timestampStr)
 73		if err != nil {
 74			// Try other common formats
 75			parsedTime, err = time.Parse("2006-01-02T15:04:05", timestampStr)
 76			if err != nil {
 77				// If all parsing fails, return the original string
 78				return timestampStr
 79			}
 80		}
 81	}
 82
 83	duration := time.Since(parsedTime)
 84
 85	// Format based on duration
 86	if duration < time.Minute {
 87		seconds := int(duration.Seconds())
 88		return fmt.Sprintf("%ds ago", seconds)
 89	} else if duration < time.Hour {
 90		minutes := int(duration.Minutes())
 91		seconds := int(duration.Seconds()) % 60
 92		if seconds == 0 {
 93			return fmt.Sprintf("%dm ago", minutes)
 94		}
 95		return fmt.Sprintf("%dm %02ds ago", minutes, seconds)
 96	} else if duration < 24*time.Hour {
 97		hours := int(duration.Hours())
 98		minutes := int(duration.Minutes()) % 60
 99		if minutes == 0 {
100			return fmt.Sprintf("%dh ago", hours)
101		}
102		return fmt.Sprintf("%dh %02dm ago", hours, minutes)
103	} else {
104		days := int(duration.Hours() / 24)
105		hours := int(duration.Hours()) % 24
106		if hours == 0 {
107			return fmt.Sprintf("%dd ago", days)
108		}
109		return fmt.Sprintf("%dd %02dh ago", days, hours)
110	}
111}
112
113// getSortField determines which field to sort by, returns "id" as default
114func getSortField() string {
115	if sortByType {
116		return "type"
117	}
118	if sortByHost {
119		return "host"
120	}
121	if sortByUser {
122		return "user"
123	}
124	if sortByProcess {
125		return "process"
126	}
127	if sortByLast {
128		return "last"
129	}
130	if sortByDescription {
131		return "description"
132	}
133	// Default to ID sort (newest/highest last)
134	return "id"
135}
136
137// sortCallbacks sorts callbacks based on the specified field
138func sortCallbacks(callbacks []mythic.Callback, sortField string, reverse bool) {
139	sort.Slice(callbacks, func(i, j int) bool {
140		var result bool
141
142		switch sortField {
143		case "type":
144			typeI := callbacks[i].Payload.PayloadType.Name
145			typeJ := callbacks[j].Payload.PayloadType.Name
146			if typeI == "" {
147				typeI = "unknown"
148			}
149			if typeJ == "" {
150				typeJ = "unknown"
151			}
152			result = strings.ToLower(typeI) < strings.ToLower(typeJ)
153		case "host":
154			result = strings.ToLower(callbacks[i].Host) < strings.ToLower(callbacks[j].Host)
155		case "user":
156			result = strings.ToLower(callbacks[i].User) < strings.ToLower(callbacks[j].User)
157		case "process":
158			result = strings.ToLower(callbacks[i].ProcessName) < strings.ToLower(callbacks[j].ProcessName)
159		case "last":
160			// Parse timestamps for comparison
161			timeI, errI := time.Parse(time.RFC3339, callbacks[i].LastCheckin)
162			timeJ, errJ := time.Parse(time.RFC3339, callbacks[j].LastCheckin)
163			if errI != nil || errJ != nil {
164				// Fallback to string comparison if parsing fails
165				result = callbacks[i].LastCheckin < callbacks[j].LastCheckin
166			} else {
167				result = timeI.Before(timeJ)
168			}
169		case "description":
170			result = strings.ToLower(callbacks[i].Description) < strings.ToLower(callbacks[j].Description)
171		default: // "id"
172			result = callbacks[i].DisplayID < callbacks[j].DisplayID
173		}
174
175		if reverse {
176			return !result
177		}
178		return result
179	})
180}
181
182func runCallbacks(cmd *cobra.Command, args []string) error {
183	if err := validateConfig(); err != nil {
184		return err
185	}
186
187	client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
188	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
189	defer cancel()
190
191	// Check if a specific callback ID was provided
192	if len(args) == 1 {
193		callbackID, err := strconv.Atoi(args[0])
194		if err != nil {
195			return fmt.Errorf("invalid callback ID: %s", args[0])
196		}
197		return showDetailedCallback(ctx, client, callbackID)
198	}
199
200	// Show table of all active callbacks
201	callbacks, err := client.GetActiveCallbacks(ctx)
202	if err != nil {
203		return fmt.Errorf("failed to get active callbacks: %w", err)
204	}
205
206	if len(callbacks) == 0 {
207		fmt.Println("No active callbacks found")
208		return nil
209	}
210
211	// Sort callbacks based on flags
212	sortField := getSortField()
213	// For ID sort, default to ascending order with highest ID last (reverse=false)
214	// For other sorts, use the --rev flag
215	shouldReverse := sortReverse
216	// Note: No special logic needed for ID sort - ascending is the natural order
217
218	sortCallbacks(callbacks, sortField, shouldReverse)
219
220	// Create a table for formatted output
221	t := table.NewWriter()
222	t.SetOutputMirror(os.Stdout)
223
224	// Configure table styling - header borders only
225	t.SetStyle(table.Style{
226		Name: "HeaderOnlyBorders",
227		Box:  table.StyleBoxDefault,
228		Options: table.Options{
229			DrawBorder:      false, // No outer border
230			SeparateColumns: false, // No column separators
231			SeparateFooter:  false, // No footer separator
232			SeparateHeader:  true,  // Keep header separator
233			SeparateRows:    false, // No row separators
234		},
235	})
236
237	// Define columns with priorities (1=highest, higher numbers can be dropped)
238	columns := []ColumnInfo{
239		{Header: "ID", Number: 1, MinWidth: 3, MaxWidth: 6, Priority: 1},       // Always show
240		{Header: "TYPE", Number: 2, MinWidth: 6, MaxWidth: 10, Priority: 3},    // High priority
241		{Header: "HOST", Number: 3, MinWidth: 8, MaxWidth: 16, Priority: 2},    // Very important
242		{Header: "USER", Number: 4, MinWidth: 6, MaxWidth: 12, Priority: 4},    // Important
243		{Header: "PROCESS", Number: 5, MinWidth: 8, MaxWidth: 12, Priority: 6}, // Can drop
244		{Header: "PID", Number: 6, MinWidth: 3, MaxWidth: 6, Priority: 7},      // Can drop
245		{Header: "LAST CHECKIN", Number: 7, MinWidth: 8, MaxWidth: 10, Priority: 5},
246		{Header: "DESCRIPTION", Number: 8, MinWidth: 10, MaxWidth: 25, Priority: 8}, // First to drop
247	}
248
249	// Configure table for current terminal width
250	dt := configureTableForTerminal(t, columns)
251
252	for _, callback := range callbacks {
253		agentType := callback.Payload.PayloadType.Name
254		if agentType == "" {
255			agentType = "unknown"
256		}
257
258		lastCheckin := formatTimeSince(callback.LastCheckin)
259
260		// Prepare all column data
261		allData := []interface{}{
262			callback.DisplayID,
263			agentType,
264			callback.Host,
265			callback.User,
266			callback.ProcessName,
267			callback.PID,
268			lastCheckin,
269			callback.Description,
270		}
271
272		// Add row with only visible columns
273		dt.AppendRowForColumns(allData)
274	}
275
276	t.Render()
277	return nil
278}
279
280// wrapDescription wraps text to fit within the specified width, adding proper indentation for continuation lines
281func wrapDescription(text string, maxWidth int, indent string) string {
282	if text == "" {
283		return ""
284	}
285
286	// If the text fits on one line, return as-is
287	if len(text) <= maxWidth {
288		return text
289	}
290
291	words := strings.Fields(text)
292	if len(words) == 0 {
293		return text
294	}
295
296	var lines []string
297	var currentLine []string
298	currentLength := 0
299
300	for _, word := range words {
301		// Check if adding this word would exceed the line width
302		wordLen := len(word)
303		if currentLength > 0 {
304			wordLen += 1 // space before word
305		}
306
307		if currentLength == 0 || currentLength + wordLen <= maxWidth {
308			// Word fits on current line
309			currentLine = append(currentLine, word)
310			if currentLength == 0 {
311				currentLength = len(word)
312			} else {
313				currentLength += 1 + len(word) // space + word
314			}
315		} else {
316			// Word doesn't fit, finish current line and start new one
317			if len(currentLine) > 0 {
318				lines = append(lines, strings.Join(currentLine, " "))
319			}
320
321			// Handle very long single words that exceed maxWidth
322			if len(word) > maxWidth {
323				// Split the word if it's longer than maxWidth
324				for len(word) > maxWidth {
325					lines = append(lines, word[:maxWidth])
326					word = word[maxWidth:]
327				}
328				if len(word) > 0 {
329					currentLine = []string{word}
330					currentLength = len(word)
331				} else {
332					currentLine = []string{}
333					currentLength = 0
334				}
335			} else {
336				// Start new line with this word
337				currentLine = []string{word}
338				currentLength = len(word)
339			}
340		}
341	}
342
343	// Add the final line
344	if len(currentLine) > 0 {
345		lines = append(lines, strings.Join(currentLine, " "))
346	}
347
348	// Join lines with appropriate indentation
349	result := lines[0]
350	for i := 1; i < len(lines); i++ {
351		result += "\n" + indent + lines[i]
352	}
353
354	return result
355}
356
357func showDetailedCallback(ctx context.Context, client *mythic.Client, callbackID int) error {
358	callback, err := client.GetDetailedCallback(ctx, callbackID)
359	if err != nil {
360		return err
361	}
362
363	// Display detailed callback information
364	fmt.Printf("Callback %d\n", callback.DisplayID)
365	fmt.Printf("\n")
366
367	// Agent Overview Section
368	fmt.Printf("Agent Overview\n")
369	fmt.Printf("  ID:              %d\n", callback.DisplayID)
370	fmt.Printf("  Status:          %s\n", getStatusString(callback.Active))
371	fmt.Printf("  Agent Type:      %s\n", callback.Payload.PayloadType.Name)
372	fmt.Printf("  Operator:        %s\n", callback.Operator.Username)
373	fmt.Printf("  Last Checkin:    %s\n", formatTimeSince(callback.LastCheckin))
374	if callback.Description != "" {
375		fmt.Printf("  Description:     %s\n", callback.Description)
376	}
377	fmt.Printf("\n")
378
379	// System Information Section
380	fmt.Printf("System Information\n")
381	fmt.Printf("  Hostname:        %s\n", callback.Host)
382	fmt.Printf("  Username:        %s\n", callback.User)
383	fmt.Printf("  Process:         %s (PID: %d)\n", callback.ProcessName, callback.PID)
384	if callback.OS != "" {
385		fmt.Printf("  Operating System: %s\n", callback.OS)
386	}
387	if callback.Architecture != "" {
388		fmt.Printf("  Architecture:    %s\n", callback.Architecture)
389	}
390	if callback.Domain != "" {
391		fmt.Printf("  Domain:          %s\n", callback.Domain)
392	}
393	if callback.IntegrityLevel > 0 {
394		fmt.Printf("  Integrity Level: %d\n", callback.IntegrityLevel)
395	}
396	fmt.Printf("\n")
397
398	// Network Context Section
399	fmt.Printf("Network Context\n")
400	if callback.IP != "" {
401		fmt.Printf("  Internal IP:     %s\n", callback.IP)
402	}
403	if callback.ExternalIP != "" {
404		fmt.Printf("  External IP:     %s\n", callback.ExternalIP)
405	}
406	if callback.SleepInfo != "" {
407		fmt.Printf("  Sleep Config:    %s\n", callback.SleepInfo)
408	}
409	if callback.CryptoType != "" {
410		fmt.Printf("  Crypto Type:     %s\n", callback.CryptoType)
411	}
412	fmt.Printf("\n")
413
414	// Payload Information Section
415	fmt.Printf("Payload Information\n")
416	fmt.Printf("  UUID:            %s\n", callback.Payload.UUID)
417	fmt.Printf("  Type:            %s\n", callback.Payload.PayloadType.Name)
418	if callback.Payload.PayloadType.Author != "" {
419		fmt.Printf("  Author:          %s\n", callback.Payload.PayloadType.Author)
420	}
421	if callback.Payload.PayloadType.Note != "" {
422		fmt.Printf("  Type Note:       %s\n", callback.Payload.PayloadType.Note)
423	}
424	if callback.Payload.Description != "" {
425		fmt.Printf("  Description:     %s\n", callback.Payload.Description)
426	}
427	if callback.AgentCallbackID != "" {
428		fmt.Printf("  Agent ID:        %s\n", callback.AgentCallbackID)
429	}
430	// Available Commands Section - only show if --commands flag is used
431	if showCommands {
432		fmt.Printf("\n")
433		fmt.Printf("Available Commands (%d loaded)\n", len(callback.LoadedCommands))
434		if len(callback.LoadedCommands) > 0 {
435			// Sort commands alphabetically
436			commands := make([]mythic.LoadedCommand, len(callback.LoadedCommands))
437			copy(commands, callback.LoadedCommands)
438			sort.Slice(commands, func(i, j int) bool {
439				return commands[i].Command.Cmd < commands[j].Command.Cmd
440			})
441
442			// Find the longest command name for consistent alignment
443			maxCmdLen := 0
444			for _, cmd := range commands {
445				if len(cmd.Command.Cmd) > maxCmdLen {
446					maxCmdLen = len(cmd.Command.Cmd)
447				}
448			}
449
450			// Calculate available width for descriptions
451			termWidth := getTerminalWidth()
452			prefixWidth := 2 + maxCmdLen + 1 // "  longest_command_name "
453			adminFlagMaxWidth := 10 // " [ADMIN]" plus some buffer
454			availableWidth := termWidth - prefixWidth - adminFlagMaxWidth
455
456			// Ensure minimum width for readability
457			if availableWidth < 30 {
458				availableWidth = 30
459			}
460
461			// Create indent string to match the command name alignment
462			indent := "  " + strings.Repeat(" ", maxCmdLen) + " "
463
464			for _, cmd := range commands {
465				adminFlag := ""
466				if cmd.Command.NeedsAdmin {
467					adminFlag = " [ADMIN]"
468				}
469
470				// Wrap the description
471				wrappedDesc := wrapDescription(cmd.Command.Description, availableWidth, indent)
472
473				// Print the command with wrapped description using dynamic width
474				fmt.Printf("  %-*s %s%s\n", maxCmdLen, cmd.Command.Cmd, wrappedDesc, adminFlag)
475			}
476		} else {
477			fmt.Printf("  No commands loaded\n")
478		}
479	}
480
481	// Show extra info if available
482	if callback.ExtraInfo != "" {
483		fmt.Printf("\nExtra Information:\n%s\n", callback.ExtraInfo)
484	}
485
486	return nil
487}
488
489func getStatusString(active bool) string {
490	if active {
491		return "Active"
492	}
493	return "Inactive"
494}