Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@ func init() {
rootCmd.AddCommand(reScanCmd)
rootCmd.AddCommand(soukCmd)
rootCmd.AddCommand(downloadCmd)
rootCmd.AddCommand(searchCmd)
}
149 changes: 149 additions & 0 deletions cmd/search.go
Original file line number Diff line number Diff line change
@@ -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 <query>",
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)),
)
}
}

77 changes: 77 additions & 0 deletions internal/webapi/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading