diff --git a/cmd/root.go b/cmd/root.go index a66dfb6..8f33ff3 100755 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,4 +60,5 @@ func init() { rootCmd.AddCommand(reScanCmd) rootCmd.AddCommand(soukCmd) rootCmd.AddCommand(downloadCmd) + rootCmd.AddCommand(searchCmd) } diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 0000000..d679075 --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,149 @@ +// Copyright 2018 Saferwall. All rights reserved. +// Use of this source code is governed by Apache v2 license +// license that can be found in the LICENSE file. + +package cmd + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/saferwall/cli/internal/webapi" + "github.com/spf13/cobra" +) + +var ( + searchPage int + searchPerPage int +) + +var searchCmd = &cobra.Command{ + Use: "search ", + Short: "Search for files on the Saferwall platform", + Long: `Search files using a query expression. + +Examples: + saferwall-cli search 'type=pe and positives>=10' + saferwall-cli search 'fs>2026 and tag=upx' --per-page 50 + saferwall-cli search 'extension=sys and positives>=10' --page 2`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + webSvc := webapi.New(cfg.Credentials.URL) + result, err := webSvc.SearchFiles(args[0], cfg.Credentials.APIKey, searchPage, searchPerPage) + if err != nil { + log.Fatalf("search failed: %v", err) + } + printSearchResults(result, searchPage, searchPerPage) + }, +} + +func init() { + searchCmd.Flags().IntVarP(&searchPage, "page", "p", 1, "Page number") + searchCmd.Flags().IntVarP(&searchPerPage, "per-page", "n", 20, "Results per page") +} + +func printSearchResults(result *webapi.SearchResult, page, perPage int) { + fmt.Println() + + if result.TotalCount == 0 { + fmt.Println(" No results found.") + fmt.Println() + return + } + + // Summary line. + start := (page-1)*perPage + 1 + end := start + len(result.Items) - 1 + fmt.Printf(" %s\n\n", + headerStyle.Render(fmt.Sprintf("Showing %d-%d of %d results", start, end, result.TotalCount)), + ) + + // Column styles. + nameCol := lipgloss.NewStyle().Width(24) + typeCol := lipgloss.NewStyle().Width(16) + sizeCol := lipgloss.NewStyle().Width(10) + detCol := lipgloss.NewStyle().Width(12) + dateCol := lipgloss.NewStyle().Width(12) + clsCol := lipgloss.NewStyle().Width(12) + + // Header row. + fmt.Printf(" %s %s %s %s %s %s %s %s\n", + styleDim.Render(fmt.Sprintf("%-64s", "SHA256")), + styleDim.Render(nameCol.Render("NAME")), + styleDim.Render(typeCol.Render("TYPE/EXT")), + styleDim.Render(sizeCol.Render("SIZE")), + styleDim.Render(detCol.Render("DETECTIONS")), + styleDim.Render(dateCol.Render("FIRST SEEN")), + styleDim.Render(dateCol.Render("LAST SCANNED")), + styleDim.Render(clsCol.Render("VERDICT")), + ) + fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 172))) + + for _, item := range result.Items { + // Name: hide if it looks like a bare hash (the API echoes the ID as name). + name := item.Name + if name == "" || looksLikeHash(name) { + name = "-" + } + if len(name) > 24 { + name = name[:21] + "..." + } + + // Type/extension column. + typeStr := item.Format + if item.Extension != "" { + typeStr += "/" + item.Extension + } + if typeStr == "" { + typeStr = "-" + } + if len(typeStr) > 16 { + typeStr = typeStr[:13] + "..." + } + + // AV detections from condensed multiav.hits/total. + detStr := "-" + if item.MultiAV.Total > 0 { + raw := fmt.Sprintf("%d/%d", item.MultiAV.Hits, item.MultiAV.Total) + if item.MultiAV.Hits > 0 { + detStr = detectStyle.Render(raw) + } else { + detStr = cleanStyle.Render(raw) + } + } + + // Timestamps: date only. + firstSeen := "-" + if item.FirstSeen != 0 { + firstSeen = time.Unix(item.FirstSeen, 0).UTC().Format("2006-01-02") + } + lastScanned := "-" + if item.LastScanned != 0 { + lastScanned = time.Unix(item.LastScanned, 0).UTC().Format("2006-01-02") + } + + fmt.Printf(" %s %s %s %s %s %s %s %s\n", + item.ID, + nameCol.Render(name), + typeCol.Render(typeStr), + sizeCol.Render(formatSize(item.Size)), + detCol.Render(detStr), + dateCol.Render(firstSeen), + dateCol.Render(lastScanned), + clsCol.Render(renderClassification(item.Classification)), + ) + } + + fmt.Println() + + // Pagination hint. + if result.PageCount > 1 { + fmt.Printf(" %s\n\n", + styleDim.Render(fmt.Sprintf("Page %d of %d — use --page to navigate", page, result.PageCount)), + ) + } +} + diff --git a/internal/webapi/files.go b/internal/webapi/files.go index a6ef09e..2cf9f7a 100644 --- a/internal/webapi/files.go +++ b/internal/webapi/files.go @@ -300,3 +300,80 @@ func (s Service) Delete(sha256, authToken string) error { defer resp.Body.Close() return nil } + +// SearchItem is the flattened file representation returned by the search endpoint. +type SearchItem struct { + ID string `json:"id"` + Name string `json:"name"` + Format string `json:"file_format"` + Extension string `json:"file_extension"` + Size int64 `json:"size"` + FirstSeen int64 `json:"first_seen"` + LastScanned int64 `json:"last_scanned"` + Classification string `json:"class"` + MultiAV SearchMultiAV `json:"multiav"` + Tags map[string]any `json:"tags"` +} + +// SearchMultiAV holds the condensed AV stats returned in search results. +type SearchMultiAV struct { + Hits int `json:"hits"` + Total int `json:"total"` +} + +// SearchResult is the paginated response from the search endpoint. +type SearchResult struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + PageCount int `json:"page_count"` + TotalCount int `json:"total_count"` + Items []SearchItem `json:"items"` +} + +// SearchFiles calls POST /v1/files/search/ with the given query expression. +func (s Service) SearchFiles(query, authToken string, page, perPage int) (*SearchResult, error) { + url := s.filesURL + "search/" + requestBody, err := json.Marshal(map[string]any{ + "query": query, + "page": page, + "per_page": perPage, + }) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Api-Key", authToken) + + resp, err := s.client.Do(request) + if err != nil { + return nil, err + } + + body := &bytes.Buffer{} + _, err = body.ReadFrom(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + var jsonBody map[string]any + if jsonErr := json.Unmarshal(body.Bytes(), &jsonBody); jsonErr == nil { + if msg, ok := jsonBody["message"].(string); ok { + return nil, errors.New(msg) + } + } + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + var result SearchResult + if err := json.Unmarshal(body.Bytes(), &result); err != nil { + return nil, err + } + return &result, nil +}