Commit 0af402d
Changed files (12)
pkg
mythic
cmd/callbacks.go
@@ -7,9 +7,9 @@ import (
"os"
"sort"
"strings"
- "text/tabwriter"
"time"
+ "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
@@ -203,10 +203,37 @@ func runCallbacks(cmd *cobra.Command, args []string) error {
sortCallbacks(callbacks, sortField, shouldReverse)
- // Create a tab writer for formatted output
- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
- fmt.Fprintln(w, "ID\tTYPE\tHOST\tUSER\tPROCESS\tPID\tLAST CHECKIN\tDESCRIPTION")
- fmt.Fprintln(w, "--\t----\t----\t----\t-------\t---\t------------\t-----------")
+ // Create a table for formatted output
+ t := table.NewWriter()
+ t.SetOutputMirror(os.Stdout)
+
+ // Configure table styling - header borders only
+ t.SetStyle(table.Style{
+ Name: "HeaderOnlyBorders",
+ Box: table.StyleBoxDefault,
+ Options: table.Options{
+ DrawBorder: false, // No outer border
+ SeparateColumns: false, // No column separators
+ SeparateFooter: false, // No footer separator
+ SeparateHeader: true, // Keep header separator
+ SeparateRows: false, // No row separators
+ },
+ })
+
+ // Define columns with priorities (1=highest, higher numbers can be dropped)
+ columns := []ColumnInfo{
+ {Header: "ID", Number: 1, MinWidth: 3, MaxWidth: 6, Priority: 1}, // Always show
+ {Header: "TYPE", Number: 2, MinWidth: 6, MaxWidth: 10, Priority: 3}, // High priority
+ {Header: "HOST", Number: 3, MinWidth: 8, MaxWidth: 16, Priority: 2}, // Very important
+ {Header: "USER", Number: 4, MinWidth: 6, MaxWidth: 12, Priority: 4}, // Important
+ {Header: "PROCESS", Number: 5, MinWidth: 8, MaxWidth: 12, Priority: 6}, // Can drop
+ {Header: "PID", Number: 6, MinWidth: 3, MaxWidth: 6, Priority: 7}, // Can drop
+ {Header: "LAST CHECKIN", Number: 7, MinWidth: 8, MaxWidth: 10, Priority: 5},
+ {Header: "DESCRIPTION", Number: 8, MinWidth: 10, MaxWidth: 25, Priority: 8}, // First to drop
+ }
+
+ // Configure table for current terminal width
+ dt := configureTableForTerminal(t, columns)
for _, callback := range callbacks {
agentType := callback.Payload.PayloadType.Name
@@ -216,7 +243,8 @@ func runCallbacks(cmd *cobra.Command, args []string) error {
lastCheckin := formatTimeSince(callback.LastCheckin)
- fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
+ // Prepare all column data
+ allData := []interface{}{
callback.DisplayID,
agentType,
callback.Host,
@@ -225,9 +253,12 @@ func runCallbacks(cmd *cobra.Command, args []string) error {
callback.PID,
lastCheckin,
callback.Description,
- )
+ }
+
+ // Add row with only visible columns
+ dt.AppendRowForColumns(allData)
}
- w.Flush()
+ t.Render()
return nil
}
\ No newline at end of file
cmd/files.go
@@ -10,24 +10,25 @@ import (
"regexp"
"strconv"
"strings"
- "text/tabwriter"
"time"
+ "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
var (
- outputDir string
- showPayloads bool
- showDownloads bool
+ outputDir string
+ showPayloads bool
+ showDownloads bool
showScreenshots bool
+ showUploads bool
)
var filesCmd = &cobra.Command{
Use: "files",
Aliases: []string{"file"},
Short: "Manage files",
- Long: "List and download files (payloads, downloads, screenshots) from Mythic. Defaults to listing all files if no subcommand is provided.",
+ Long: "List and download files (payloads, downloads, uploads, screenshots) from Mythic. Defaults to listing all files if no subcommand is provided.",
RunE: runFilesList, // Default to list command
}
@@ -48,6 +49,7 @@ func init() {
filesCmd.Flags().BoolVarP(&showPayloads, "payload", "p", false, "Show only payload files")
filesCmd.Flags().BoolVarP(&showDownloads, "downloads", "d", false, "Show only downloaded files")
filesCmd.Flags().BoolVarP(&showScreenshots, "screenshots", "s", false, "Show only screenshot files")
+ filesCmd.Flags().BoolVarP(&showUploads, "uploads", "u", false, "Show only uploaded files (assemblies, etc)")
filesDownloadCmd.Flags().StringVarP(&outputDir, "output", "o", ".", "Output directory for downloaded files")
}
@@ -67,12 +69,13 @@ func runFilesList(cmd *cobra.Command, args []string) error {
}
// Apply filters if any are specified
- if showPayloads || showDownloads || showScreenshots {
+ if showPayloads || showDownloads || showScreenshots || showUploads {
var filteredFiles []mythic.File
for _, file := range files {
if (showPayloads && file.IsPayload) ||
(showDownloads && file.IsDownloadFromAgent && !file.IsPayload && !file.IsScreenshot) ||
- (showScreenshots && file.IsScreenshot) {
+ (showScreenshots && file.IsScreenshot) ||
+ (showUploads && !file.IsDownloadFromAgent && !file.IsPayload && !file.IsScreenshot) {
filteredFiles = append(filteredFiles, file)
}
}
@@ -86,7 +89,7 @@ func runFilesList(cmd *cobra.Command, args []string) error {
// Determine filter description
filterDesc := ""
- if showPayloads || showDownloads || showScreenshots {
+ if showPayloads || showDownloads || showScreenshots || showUploads {
var filters []string
if showPayloads {
filters = append(filters, "payloads")
@@ -97,15 +100,46 @@ func runFilesList(cmd *cobra.Command, args []string) error {
if showScreenshots {
filters = append(filters, "screenshots")
}
+ if showUploads {
+ filters = append(filters, "uploads")
+ }
filterDesc = fmt.Sprintf(" (%s)", strings.Join(filters, ", "))
}
fmt.Printf("Files%s (%d total):\n\n", filterDesc, len(files))
- // Create a tab writer for formatted output
- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
- fmt.Fprintln(w, "UUID\tTYPE\tFILENAME\tREMOTE PATH\tHOST\tSIZE\tCOMPLETE\tTASK\tTIMESTAMP")
- fmt.Fprintln(w, "----\t----\t--------\t-----------\t----\t----\t--------\t----\t---------")
+ // Create a table for formatted output
+ t := table.NewWriter()
+ t.SetOutputMirror(os.Stdout)
+
+ // Configure table styling - header borders only
+ t.SetStyle(table.Style{
+ Name: "HeaderOnlyBorders",
+ Box: table.StyleBoxDefault,
+ Options: table.Options{
+ DrawBorder: false, // No outer border
+ SeparateColumns: false, // No column separators
+ SeparateFooter: false, // No footer separator
+ SeparateHeader: true, // Keep header separator
+ SeparateRows: false, // No row separators
+ },
+ })
+
+ // Define columns with priorities (1=highest, higher numbers can be dropped)
+ columns := []ColumnInfo{
+ {Header: "UUID", Number: 1, MinWidth: 36, MaxWidth: 36, Priority: 1}, // Always show full UUID
+ {Header: "TYPE", Number: 2, MinWidth: 6, MaxWidth: 8, Priority: 2}, // High priority
+ {Header: "FILENAME", Number: 3, MinWidth: 8, MaxWidth: 16, Priority: 3}, // Important
+ {Header: "REMOTE PATH", Number: 4, MinWidth: 10, MaxWidth: 20, Priority: 7}, // Lower priority
+ {Header: "HOST", Number: 5, MinWidth: 6, MaxWidth: 10, Priority: 5},
+ {Header: "SIZE", Number: 6, MinWidth: 5, MaxWidth: 8, Priority: 8}, // Can drop
+ {Header: "COMPLETE", Number: 7, MinWidth: 4, MaxWidth: 6, Priority: 4}, // Important
+ {Header: "TASK", Number: 8, MinWidth: 4, MaxWidth: 6, Priority: 9}, // Can drop
+ {Header: "TIMESTAMP", Number: 9, MinWidth: 8, MaxWidth: 12, Priority: 6}, // Can drop
+ }
+
+ // Configure table for current terminal width
+ dt := configureTableForTerminal(t, columns)
for _, file := range files {
// Show completion status
@@ -122,16 +156,9 @@ func runFilesList(cmd *cobra.Command, args []string) error {
}
}
- // Truncate paths if too long
+ // Use full paths - table library will handle truncation
filename := file.Filename
- if len(filename) > 25 {
- filename = filename[:22] + "..."
- }
-
remotePath := file.FullRemotePath
- if len(remotePath) > 40 {
- remotePath = "..." + remotePath[len(remotePath)-37:]
- }
// Show file size info (chunks can give us a rough idea)
sizeInfo := "unknown"
@@ -145,10 +172,13 @@ func runFilesList(cmd *cobra.Command, args []string) error {
fileType = "payload"
} else if file.IsScreenshot {
fileType = "screenshot"
+ } else if !file.IsDownloadFromAgent {
+ fileType = "upload"
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\n",
- file.AgentFileID[:api.UUIDDisplayLength]+"...", // Show first 8 chars of UUID
+ // Prepare all column data
+ allData := []interface{}{
+ file.AgentFileID, // Show full UUID
fileType,
filename,
remotePath,
@@ -157,13 +187,15 @@ func runFilesList(cmd *cobra.Command, args []string) error {
status,
file.TaskDisplayID,
timestamp,
- )
+ }
+
+ // Add row with only visible columns
+ dt.AppendRowForColumns(allData)
}
- w.Flush()
+ t.Render()
fmt.Println("\nTo download a file: mysh files download <uuid>")
- fmt.Println("To view full UUIDs: mysh files | grep -v UUID")
return nil
}
cmd/grep.go
@@ -10,12 +10,18 @@ import (
"path/filepath"
"regexp"
"strings"
- "text/tabwriter"
"time"
+ "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
+type rawMatch struct {
+ taskID int
+ sourceType string // "command" or "output"
+ content string
+}
+
var (
grepCallbackID int
grepCaseInsensitive bool
@@ -25,6 +31,7 @@ var (
grepSearchCommands bool
grepSearchOutput bool
grepCacheOnly bool
+ grepRaw bool
)
var grepCmd = &cobra.Command{
@@ -49,6 +56,7 @@ func init() {
grepCmd.Flags().BoolVar(&grepSearchCommands, "commands-only", false, "Search only in command execution strings (command + params)")
grepCmd.Flags().BoolVar(&grepSearchOutput, "output-only", false, "Search only in task response outputs")
grepCmd.Flags().BoolVar(&grepCacheOnly, "cache-only", false, "Search only cached tasks (don't fetch new data from server)")
+ grepCmd.Flags().BoolVar(&grepRaw, "raw", false, "Display raw output with grep -R style single-line file delimiters")
}
@@ -167,11 +175,13 @@ func runGrep(cmd *cobra.Command, args []string) error {
// Search through tasks in memory (much faster since we have all data)
var matchingTasks []mythic.Task
+ var rawMatches []rawMatch
processedTasks := 0
for _, task := range tasks {
processedTasks++
var matches bool
+ var taskMatches []rawMatch
// Check command string if enabled
if searchCommands {
@@ -179,12 +189,30 @@ func runGrep(cmd *cobra.Command, args []string) error {
if task.Params != "" {
commandString += " " + task.Params
}
- matches = matcher(commandString)
+ if matcher(commandString) {
+ matches = true
+ if grepRaw {
+ taskMatches = append(taskMatches, rawMatch{
+ taskID: task.DisplayID,
+ sourceType: "command",
+ content: commandString,
+ })
+ }
+ }
}
- // Check response output if enabled and not already matched
- if !matches && searchOutput && task.Response != "" {
- matches = matcher(task.Response)
+ // Check response output if enabled
+ if searchOutput && task.Response != "" {
+ if matcher(task.Response) {
+ matches = true
+ if grepRaw {
+ taskMatches = append(taskMatches, rawMatch{
+ taskID: task.DisplayID,
+ sourceType: "output",
+ content: task.Response,
+ })
+ }
+ }
}
// Apply invert logic
@@ -194,6 +222,9 @@ func runGrep(cmd *cobra.Command, args []string) error {
if matches {
matchingTasks = append(matchingTasks, task)
+ if grepRaw {
+ rawMatches = append(rawMatches, taskMatches...)
+ }
}
}
@@ -202,26 +233,73 @@ func runGrep(cmd *cobra.Command, args []string) error {
return nil
}
- // Show results in table format
- showMatchingTasksTable(matchingTasks)
+ // Show results
+ if grepRaw {
+ showRawMatches(rawMatches)
+ } else {
+ showMatchingTasksTable(matchingTasks)
+ }
fmt.Printf("\nFound %d matches in %d tasks searched\n", len(matchingTasks), processedTasks)
return nil
}
+func showRawMatches(matches []rawMatch) {
+ for i, match := range matches {
+ // Format like tail -f: ==> filename <==
+ // Use task ID and source type as the "filename"
+ filename := fmt.Sprintf("task_%d_%s", match.taskID, match.sourceType)
+
+ // Add separator between files (except for the first one)
+ if i > 0 {
+ fmt.Println()
+ }
+
+ fmt.Printf("==> %s <==\n", filename)
+ fmt.Print(match.content)
+
+ // Ensure content ends with newline if it doesn't already
+ if !strings.HasSuffix(match.content, "\n") {
+ fmt.Println()
+ }
+ }
+}
+
func showMatchingTasksTable(tasks []mythic.Task) {
- // Create a tab writer for formatted output
- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
- fmt.Fprintln(w, "TASK ID\tCOMMAND\tPARAMS\tSTATUS\tCALLBACK\tTIMESTAMP")
- fmt.Fprintln(w, "-------\t-------\t------\t------\t--------\t---------")
+ // Create a table for formatted output
+ t := table.NewWriter()
+ t.SetOutputMirror(os.Stdout)
+
+ // Configure table styling - header borders only
+ t.SetStyle(table.Style{
+ Name: "HeaderOnlyBorders",
+ Box: table.StyleBoxDefault,
+ Options: table.Options{
+ DrawBorder: false, // No outer border
+ SeparateColumns: false, // No column separators
+ SeparateFooter: false, // No footer separator
+ SeparateHeader: true, // Keep header separator
+ SeparateRows: false, // No row separators
+ },
+ })
+
+ // Define columns with priorities (1=highest, higher numbers can be dropped)
+ columns := []ColumnInfo{
+ {Header: "TASK ID", Number: 1, MinWidth: 6, MaxWidth: 8, Priority: 1}, // Always show
+ {Header: "COMMAND", Number: 2, MinWidth: 8, MaxWidth: 12, Priority: 2}, // High priority
+ {Header: "PARAMS", Number: 3, MinWidth: 10, MaxWidth: 30, Priority: 5}, // Can drop
+ {Header: "STATUS", Number: 4, MinWidth: 6, MaxWidth: 10, Priority: 3}, // Important
+ {Header: "CALLBACK", Number: 5, MinWidth: 6, MaxWidth: 8, Priority: 4}, // Important
+ {Header: "TIMESTAMP", Number: 6, MinWidth: 10, MaxWidth: 14, Priority: 6}, // First to drop
+ }
+
+ // Configure table for current terminal width
+ dt := configureTableForTerminal(t, columns)
for _, task := range tasks {
- // Truncate params if too long
+ // Use full params - table library will handle truncation
params := task.Params
- if len(params) > 50 {
- params = params[:47] + "..."
- }
// Format timestamp
timestamp := task.Timestamp
@@ -231,17 +309,21 @@ func showMatchingTasksTable(tasks []mythic.Task) {
}
}
- fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n",
+ // Prepare all column data
+ allData := []interface{}{
task.DisplayID,
task.Command,
params,
task.Status,
task.CallbackID,
timestamp,
- )
+ }
+
+ // Add row with only visible columns
+ dt.AppendRowForColumns(allData)
}
- w.Flush()
+ t.Render()
}
// populateTaskResponses efficiently loads response data for tasks using cache when possible
cmd/payload.go
@@ -7,9 +7,9 @@ import (
"os"
"strconv"
"strings"
- "text/tabwriter"
"time"
+ "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
@@ -61,26 +61,46 @@ func showPayloadList(ctx context.Context, client *mythic.Client) error {
fmt.Printf("Payloads (%d total):\n\n", len(payloads))
- // Create a tab writer for formatted output
- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
- fmt.Fprintln(w, "ID\tTYPE\tDESCRIPTION\tFILENAME\tBUILD STATUS\tC2 PROFILES\tOPERATOR\tCREATED")
- fmt.Fprintln(w, "--\t----\t-----------\t--------\t------------\t-----------\t--------\t-------")
+ // Create a table for formatted output
+ t := table.NewWriter()
+ t.SetOutputMirror(os.Stdout)
+
+ // Configure table styling - header borders only
+ t.SetStyle(table.Style{
+ Name: "HeaderOnlyBorders",
+ Box: table.StyleBoxDefault,
+ Options: table.Options{
+ DrawBorder: false, // No outer border
+ SeparateColumns: false, // No column separators
+ SeparateFooter: false, // No footer separator
+ SeparateHeader: true, // Keep header separator
+ SeparateRows: false, // No row separators
+ },
+ })
+
+ // Define columns with priorities (1=highest, higher numbers can be dropped)
+ columns := []ColumnInfo{
+ {Header: "ID", Number: 1, MinWidth: 3, MaxWidth: 6, Priority: 1}, // Always show
+ {Header: "TYPE", Number: 2, MinWidth: 6, MaxWidth: 10, Priority: 2}, // High priority
+ {Header: "DESCRIPTION", Number: 3, MinWidth: 10, MaxWidth: 20, Priority: 5}, // Can drop
+ {Header: "FILENAME", Number: 4, MinWidth: 8, MaxWidth: 16, Priority: 3}, // Important
+ {Header: "BUILD STATUS", Number: 5, MinWidth: 6, MaxWidth: 10, Priority: 4}, // Important
+ {Header: "C2 PROFILES", Number: 6, MinWidth: 8, MaxWidth: 12, Priority: 6}, // Can drop
+ {Header: "OPERATOR", Number: 7, MinWidth: 6, MaxWidth: 10, Priority: 7}, // Can drop
+ {Header: "CREATED", Number: 8, MinWidth: 10, MaxWidth: 14, Priority: 8}, // First to drop
+ }
+
+ // Configure table for current terminal width
+ dt := configureTableForTerminal(t, columns)
for _, payload := range payloads {
- // Format description
+ // Use full values - table library will handle truncation
description := payload.Description
- if len(description) > 30 {
- description = description[:27] + "..."
- }
if description == "" {
description = "-"
}
- // Format filename
filename := payload.Filename
- if len(filename) > 20 {
- filename = filename[:17] + "..."
- }
if filename == "" {
filename = "-"
}
@@ -93,9 +113,6 @@ func showPayloadList(ctx context.Context, client *mythic.Client) error {
// Format C2 profiles
c2ProfilesStr := strings.Join(payload.C2Profiles, ",")
- if len(c2ProfilesStr) > 20 {
- c2ProfilesStr = c2ProfilesStr[:17] + "..."
- }
if c2ProfilesStr == "" {
c2ProfilesStr = "-"
}
@@ -114,7 +131,8 @@ func showPayloadList(ctx context.Context, client *mythic.Client) error {
typeStr += "*"
}
- fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ // Prepare all column data
+ allData := []interface{}{
payload.ID,
typeStr,
description,
@@ -123,10 +141,13 @@ func showPayloadList(ctx context.Context, client *mythic.Client) error {
c2ProfilesStr,
payload.OperatorUsername,
created,
- )
+ }
+
+ // Add row with only visible columns
+ dt.AppendRowForColumns(allData)
}
- w.Flush()
+ t.Render()
fmt.Println("\n* = Auto-generated payload")
fmt.Println("\nTo view payload details: mysh payload <id>")
cmd/root.go
@@ -4,7 +4,9 @@ import (
"fmt"
"os"
+ "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
+ "golang.org/x/term"
)
var (
@@ -60,3 +62,187 @@ func validateConfig() error {
return nil
}
+// getTerminalWidth returns the current terminal width, with a fallback to 80
+func getTerminalWidth() int {
+ if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 {
+ return width
+ }
+ return 80 // fallback width
+}
+
+// ColumnInfo represents a table column with its properties
+type ColumnInfo struct {
+ Header string
+ Number int
+ MinWidth int
+ MaxWidth int
+ Priority int // 1 = highest priority (never drop), higher numbers can be dropped first
+}
+
+// DynamicTable wraps a table with information about visible columns
+type DynamicTable struct {
+ table.Writer
+ VisibleColumns []ColumnInfo
+}
+
+// configureTableForTerminal sets up a table with dynamic column sizing based on terminal width
+func configureTableForTerminal(t table.Writer, columns []ColumnInfo) *DynamicTable {
+ termWidth := getTerminalWidth()
+
+ // Reserve space for table library's internal padding and margins
+ availableWidth := termWidth - 4 // -4 to account for table library overhead
+
+ // Filter columns that can fit and distribute width
+ visibleColumns, columnWidths := distributeColumnWidths(columns, availableWidth)
+
+ // Create headers and column configs for visible columns
+ var headers []interface{}
+ var configs []table.ColumnConfig
+
+ for i, col := range visibleColumns {
+ headers = append(headers, col.Header)
+ configs = append(configs, table.ColumnConfig{
+ Number: i + 1, // Use sequential numbers for visible columns
+ WidthMin: columnWidths[i], // Force minimum width
+ WidthMax: columnWidths[i], // Force maximum width
+ WidthMaxEnforcer: truncateWithEllipsis,
+ })
+ }
+
+ t.AppendHeader(table.Row(headers))
+ t.SetColumnConfigs(configs)
+ // Don't set AllowedRowLength since we're managing column widths precisely
+
+ return &DynamicTable{
+ Writer: t,
+ VisibleColumns: visibleColumns,
+ }
+}
+
+// AppendRowForColumns adds a row to the table using only the visible columns
+func (dt *DynamicTable) AppendRowForColumns(allData []interface{}) {
+ var visibleData []interface{}
+ for _, col := range dt.VisibleColumns {
+ if col.Number-1 < len(allData) {
+ visibleData = append(visibleData, allData[col.Number-1])
+ }
+ }
+ dt.AppendRow(table.Row(visibleData))
+}
+
+// distributeColumnWidths filters columns by priority and distributes available width
+func distributeColumnWidths(columns []ColumnInfo, availableWidth int) ([]ColumnInfo, []int) {
+ // Sort by priority (lower number = higher priority)
+ sortedCols := make([]ColumnInfo, len(columns))
+ copy(sortedCols, columns)
+
+ // Simple bubble sort by priority
+ for i := 0; i < len(sortedCols)-1; i++ {
+ for j := 0; j < len(sortedCols)-i-1; j++ {
+ if sortedCols[j].Priority > sortedCols[j+1].Priority {
+ sortedCols[j], sortedCols[j+1] = sortedCols[j+1], sortedCols[j]
+ }
+ }
+ }
+
+ // Step 1: Find which columns can fit using minimum widths
+ // Try to fit columns in priority order (lowest priority number = highest priority)
+ var visibleColumns []ColumnInfo
+ totalMinWidth := 0
+
+ for _, col := range sortedCols {
+ // Check if adding this column would exceed available width
+ // Add 1 space per column for minimal padding
+ newTotal := totalMinWidth + col.MinWidth + 1
+ if newTotal <= availableWidth {
+ visibleColumns = append(visibleColumns, col)
+ totalMinWidth = newTotal
+ } else {
+ // Can't fit this column or any remaining lower priority columns
+ break
+ }
+ }
+
+ // Ensure we have at least one column (highest priority)
+ if len(visibleColumns) == 0 && len(sortedCols) > 0 {
+ visibleColumns = append(visibleColumns, sortedCols[0])
+ totalMinWidth = sortedCols[0].MinWidth + 1 // Include minimal padding
+ }
+
+ // Step 2: Distribute available width across visible columns
+ columnWidths := make([]int, len(visibleColumns))
+
+ // Start with minimum widths (totalMinWidth already includes padding)
+ actualContentWidth := 0
+ for i, col := range visibleColumns {
+ columnWidths[i] = col.MinWidth
+ actualContentWidth += col.MinWidth
+ }
+
+ // Calculate remaining width to distribute (subtract actual content width, not totalMinWidth with padding)
+ remainingWidth := availableWidth - totalMinWidth
+
+ if remainingWidth > 0 {
+ // Calculate total expansion capacity for proportional distribution
+ totalExpansion := 0
+ for _, col := range visibleColumns {
+ expansion := col.MaxWidth - col.MinWidth
+ if expansion > 0 {
+ totalExpansion += expansion
+ }
+ }
+
+ if totalExpansion > 0 {
+ // Distribute remaining width proportionally
+ for i, col := range visibleColumns {
+ expansion := col.MaxWidth - col.MinWidth
+ if expansion > 0 {
+ extraWidth := (remainingWidth * expansion) / totalExpansion
+ columnWidths[i] = col.MinWidth + extraWidth
+ // Ensure we don't exceed max width
+ if columnWidths[i] > col.MaxWidth {
+ columnWidths[i] = col.MaxWidth
+ }
+ }
+ }
+
+ // Distribute any leftover width (due to rounding or max width constraints)
+ actualUsed := 0
+ for _, width := range columnWidths {
+ actualUsed += width
+ }
+ leftover := availableWidth - actualUsed
+
+ // Only distribute leftover if it's positive and reasonable
+ if leftover > 0 && leftover < len(visibleColumns)*2 {
+ for i := 0; i < leftover && i < len(visibleColumns); i++ {
+ if columnWidths[i] < visibleColumns[i].MaxWidth {
+ columnWidths[i]++
+ }
+ }
+ }
+ }
+ }
+
+ return visibleColumns, columnWidths
+}
+
+// filterColumnsForWidth removes low-priority columns until the table fits in available width
+// Kept for backwards compatibility, but now uses distributeColumnWidths internally
+func filterColumnsForWidth(columns []ColumnInfo, availableWidth int) []ColumnInfo {
+ visibleColumns, _ := distributeColumnWidths(columns, availableWidth)
+ return visibleColumns
+}
+
+// truncateWithEllipsis is a helper function for table column width enforcement
+// It truncates text that exceeds maxLen and adds "..." at the end
+func truncateWithEllipsis(col string, maxLen int) string {
+ if len(col) <= maxLen {
+ return col
+ }
+ if maxLen <= 3 {
+ return col[:maxLen]
+ }
+ return col[:maxLen-3] + "..."
+}
+
cmd/task_list.go
@@ -6,9 +6,9 @@ import (
"mysh/pkg/mythic"
"os"
"strconv"
- "text/tabwriter"
"time"
+ "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
@@ -90,23 +90,51 @@ func runTaskList(cmd *cobra.Command, args []string) error {
fmt.Printf("Tasks for callback %s (showing %d, newest last):\n\n", args[0], len(tasks))
}
- // Create a tab writer for formatted output
- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
-
+ // Create a table for formatted output
+ t := table.NewWriter()
+ t.SetOutputMirror(os.Stdout)
+
+ // Configure table styling - header borders only
+ t.SetStyle(table.Style{
+ Name: "HeaderOnlyBorders",
+ Box: table.StyleBoxDefault,
+ Options: table.Options{
+ DrawBorder: false, // No outer border
+ SeparateColumns: false, // No column separators
+ SeparateFooter: false, // No footer separator
+ SeparateHeader: true, // Keep header separator
+ SeparateRows: false, // No row separators
+ },
+ })
+
+ var columns []ColumnInfo
if showingAllCallbacks {
- fmt.Fprintln(w, "TASK ID\tCALLBACK\tCOMMAND\tPARAMS\tSTATUS\tTIMESTAMP")
- fmt.Fprintln(w, "-------\t--------\t-------\t------\t------\t---------")
+ // Define columns with priorities for all callbacks view
+ columns = []ColumnInfo{
+ {Header: "TASK ID", Number: 1, MinWidth: 6, MaxWidth: 8, Priority: 1}, // Always show
+ {Header: "CALLBACK", Number: 2, MinWidth: 6, MaxWidth: 8, Priority: 2}, // High priority
+ {Header: "COMMAND", Number: 3, MinWidth: 8, MaxWidth: 15, Priority: 3}, // Important
+ {Header: "PARAMS", Number: 4, MinWidth: 10, MaxWidth: 40, Priority: 5}, // Can drop
+ {Header: "STATUS", Number: 5, MinWidth: 8, MaxWidth: 12, Priority: 4}, // Important
+ {Header: "TIMESTAMP", Number: 6, MinWidth: 12, MaxWidth: 19, Priority: 6}, // First to drop
+ }
} else {
- fmt.Fprintln(w, "TASK ID\tCOMMAND\tPARAMS\tSTATUS\tTIMESTAMP")
- fmt.Fprintln(w, "-------\t-------\t------\t------\t---------")
+ // Define columns with priorities for single callback view
+ columns = []ColumnInfo{
+ {Header: "TASK ID", Number: 1, MinWidth: 6, MaxWidth: 8, Priority: 1}, // Always show
+ {Header: "COMMAND", Number: 2, MinWidth: 8, MaxWidth: 15, Priority: 2}, // High priority
+ {Header: "PARAMS", Number: 3, MinWidth: 10, MaxWidth: 50, Priority: 4}, // Can drop
+ {Header: "STATUS", Number: 4, MinWidth: 8, MaxWidth: 12, Priority: 3}, // Important
+ {Header: "TIMESTAMP", Number: 5, MinWidth: 12, MaxWidth: 19, Priority: 5}, // First to drop
+ }
}
+ // Configure table for current terminal width
+ dt := configureTableForTerminal(t, columns)
+
for _, task := range tasks {
- // Truncate params if too long
+ // Use full params - table library will handle truncation
params := task.Params
- if len(params) > 50 {
- params = params[:47] + "..."
- }
// Format timestamp
timestamp := task.Timestamp
@@ -116,27 +144,32 @@ func runTaskList(cmd *cobra.Command, args []string) error {
}
}
+ // Prepare all column data
+ var allData []interface{}
if showingAllCallbacks {
- fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%s\n",
+ allData = []interface{}{
task.DisplayID,
task.CallbackID,
task.Command,
params,
task.Status,
timestamp,
- )
+ }
} else {
- fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
+ allData = []interface{}{
task.DisplayID,
task.Command,
params,
task.Status,
timestamp,
- )
+ }
}
+
+ // Add row with only visible columns
+ dt.AppendRowForColumns(allData)
}
- w.Flush()
+ t.Render()
return nil
}
\ No newline at end of file
pkg/mythic/client.go
@@ -190,7 +190,7 @@ func decodeResponseText(responseText string) string {
func (c *Client) GetCallbacks(ctx context.Context) ([]Callback, error) {
req := graphql.NewRequest(GetCallbacks)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Callback []Callback `json:"callback"`
@@ -226,7 +226,7 @@ func (c *Client) CreateTask(ctx context.Context, callbackID int, command, params
req.Var("command", command)
req.Var("params", params)
req.Var("tasking_location", "command_line")
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
CreateTask struct {
@@ -259,7 +259,7 @@ func (c *Client) GetTaskResponse(ctx context.Context, taskID int) (*Task, error)
req := graphql.NewRequest(GetTask)
req.Var("id", taskID)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Task struct {
@@ -285,7 +285,7 @@ func (c *Client) GetTaskResponse(ctx context.Context, taskID int) (*Task, error)
responseReq := graphql.NewRequest(GetTaskResponses)
responseReq.Var("task_display_id", resp.Task.DisplayID)
- responseReq.Header.Set("Authorization", "Bearer "+c.token)
+ responseReq.Header.Set("apitoken", c.token)
var responseResp struct {
Response []struct {
@@ -331,7 +331,7 @@ func (c *Client) GetTasksWithResponsesByIDs(ctx context.Context, taskIDs []int)
req := graphql.NewRequest(GetTasksWithResponsesByIDs)
req.Var("task_ids", taskIDs)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Task []struct {
@@ -395,7 +395,7 @@ func (c *Client) GetCallbackTasks(ctx context.Context, callbackID int) ([]Task,
req := graphql.NewRequest(GetCallbackTasks)
req.Var("callback_id", callbackID)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Task []struct {
@@ -443,7 +443,7 @@ func (c *Client) GetCallbackTasks(ctx context.Context, callbackID int) ([]Task,
func (c *Client) GetDownloadedFiles(ctx context.Context) ([]File, error) {
req := graphql.NewRequest(GetDownloadedFiles)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Filemeta []struct {
@@ -507,7 +507,7 @@ func (c *Client) GetDownloadedFiles(ctx context.Context) ([]File, error) {
func (c *Client) GetAllFiles(ctx context.Context) ([]File, error) {
req := graphql.NewRequest(GetAllFiles)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Filemeta []struct {
@@ -582,7 +582,7 @@ func (c *Client) DownloadFile(ctx context.Context, fileUUID string) ([]byte, err
return nil, fmt.Errorf("failed to create download request: %w", err)
}
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -611,7 +611,7 @@ func (c *Client) GetTasksWithResponses(ctx context.Context, callbackID int, limi
if limit > 0 {
req.Var("limit", limit)
}
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Task []struct {
@@ -677,7 +677,7 @@ func (c *Client) GetAllTasks(ctx context.Context, limit int) ([]Task, error) {
if limit > 0 {
req.Var("limit", limit)
}
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Task []struct {
@@ -733,7 +733,7 @@ func (c *Client) GetAllTasksWithResponses(ctx context.Context, limit int) ([]Tas
if limit > 0 {
req.Var("limit", limit)
}
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Task []struct {
@@ -796,7 +796,7 @@ func (c *Client) GetAllTasksWithResponses(ctx context.Context, limit int) ([]Tas
func (c *Client) GetPayloads(ctx context.Context) ([]Payload, error) {
req := graphql.NewRequest(GetPayloads)
- req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("apitoken", c.token)
var resp struct {
Payload []struct {
.gitignore
@@ -1,4 +1,4 @@
bin/*
mythic.sh
-contrib/Mythic_Scripting
+contrib/*
.env
go.mod
@@ -9,8 +9,14 @@ require (
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jedib0t/go-pretty/v6 v6.6.8 // indirect
github.com/matryer/is v1.4.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.44.0 // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/term v0.35.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
)
go.sum
@@ -1,12 +1,19 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
+github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/machinebox/graphql v0.2.2 h1:dWKpJligYKhYKO5A2gvNhkJdQMNZeChZYyBbrZkBZfo=
github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
@@ -14,5 +21,11 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
+golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
TODO.md
@@ -2,6 +2,7 @@
- MYTHIC_API_INSECURE= boolean --insecure flag - and invert default = false
- update output table formats to respect terminal width
- invert cobra-cli nto functions (no global vars)
+- ✅ grep --raw - only diplay of all matching items raw output follow grep -R style single-line file dilimeters in the output
- ✅ use per-server cache dirs
- ✅ make sure raw output has a newline at the end
- ✅ make mysh cache default to info output
USAGE.md
@@ -1,155 +0,0 @@
-# `mysh` Usage Examples
-
-## Building
-
-### Using Makefile (Recommended)
-```bash
-# Build for current platform
-make build
-
-# Build for all platforms
-make build-all
-
-# Development build (faster)
-make dev
-
-# Clean build artifacts
-make clean
-
-# Show all available targets
-make help
-```
-
-### Manual Build
-```bash
-go build -o bin/mysh .
-```
-
-## Project Structure
-
-### GraphQL Queries Synchronization
-The project maintains a 1:1 copy of GraphQL queries from the Python Mythic_Scripting library:
-- **Source:** `contrib/Mythic_Scripting/mythic/graphql_queries.py`
-- **Go Version:** `pkg/mythic/queries.go`
-- **Purpose:** Easier upstream synchronization and reduced raw strings in client code
-
-To sync with upstream updates:
-1. Check the Python file for changes
-2. Update the corresponding Go constants
-3. Test client functionality
-
-## Basic Commands
-
-### List Active Callbacks
-```bash
-./mysh callbacks
-```
-
-### Execute Commands
-```bash
-# Get help for all commands
-./mysh exec 1 help
-
-# Get detailed help for a specific command
-./mysh exec 1 help ps
-
-# List processes
-./mysh exec 1 ps
-
-# Set checkin frequency to 5 seconds
-./mysh exec 1 sleep 5
-```
-
-### Interactive Session
-```bash
-./mysh interact 1
-```
-
-## Using SOCKS5 Proxy
-
-The CLI supports SOCKS5 proxy routing for all network traffic, replacing the need for proxychains:
-
-```bash
-# Use SOCKS5 proxy (equivalent to proxychains)
-./mysh --socks 127.0.0.1:9050 callbacks
-
-# Execute command through proxy
-./mysh --socks 127.0.0.1:9050 exec 1 ps
-
-# Interactive session through proxy
-./mysh --socks 127.0.0.1:9050 interact 1
-```
-
-## Configuration
-
-**Both URL and authentication token are required.**
-
-### Quick Setup
-```bash
-# Set via environment variables (recommended)
-export MYTHIC_API_URL="https://your-mythic-server:7443/graphql/"
-export MYTHIC_API_TOKEN="your-jwt-token-here"
-./mysh callbacks
-
-# For testing with mythic.sh
-export MYTHIC_API_URL="https://mythic.adversarytactics.local:7443/graphql/"
-export MYTHIC_API_TOKEN="$(grep TOKEN mythic.sh | cut -d'=' -f2)"
-./mysh callbacks
-```
-
-### Individual Configuration Options
-
-#### Mythic Server URL
-```bash
-# Set via environment variable
-export MYTHIC_API_URL="https://your-mythic-server:7443/graphql/"
-
-# Set via command line flag
-./mysh --url "https://your-mythic-server:7443/graphql/" callbacks
-```
-
-#### Authentication Token
-```bash
-# Set via environment variable
-export MYTHIC_API_TOKEN="your-jwt-token-here"
-
-# Set via command line flag
-./mysh --token "your-jwt-token-here" callbacks
-
-# Get token from mythic.sh for testing
-export MYTHIC_API_TOKEN="$(grep TOKEN mythic.sh | cut -d'=' -f2)"
-```
-
-## Lab 01 Equivalents
-
-These commands accomplish the goals from labs/01.md:
-
-1. **View Active Callbacks** (equivalent to clicking Active Callbacks in UI):
- ```bash
- ./mysh callbacks
- ```
-
-2. **Interact with Agent** (equivalent to right-click → Interact):
- ```bash
- ./mysh interact <callback_id>
- ```
-
-3. **Get Help** (equivalent to typing "help" in UI console):
- ```bash
- ./mysh exec <callback_id> help
- ```
-
-4. **Get Command Details** (equivalent to typing "help ps"):
- ```bash
- ./mysh exec <callback_id> help ps
- ```
-
-5. **List Processes** (equivalent to typing "ps"):
- ```bash
- ./mysh exec <callback_id> ps
- ```
-
-6. **Set Checkin Frequency** (equivalent to typing "sleep 5"):
- ```bash
- ./mysh exec <callback_id> sleep 5
- ```