Skip to content

Commit 93c3ae6

Browse files
committed
feat: add support for searching in saferwall DB
1 parent 03d3d9a commit 93c3ae6

3 files changed

Lines changed: 227 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,5 @@ func init() {
6060
rootCmd.AddCommand(reScanCmd)
6161
rootCmd.AddCommand(soukCmd)
6262
rootCmd.AddCommand(downloadCmd)
63+
rootCmd.AddCommand(searchCmd)
6364
}

cmd/search.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright 2018 Saferwall. All rights reserved.
2+
// Use of this source code is governed by Apache v2 license
3+
// license that can be found in the LICENSE file.
4+
5+
package cmd
6+
7+
import (
8+
"fmt"
9+
"log"
10+
"strings"
11+
"time"
12+
13+
"github.com/charmbracelet/lipgloss"
14+
"github.com/saferwall/cli/internal/webapi"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
var (
19+
searchPage int
20+
searchPerPage int
21+
)
22+
23+
var searchCmd = &cobra.Command{
24+
Use: "search <query>",
25+
Short: "Search for files on the Saferwall platform",
26+
Long: `Search files using a query expression.
27+
28+
Examples:
29+
saferwall-cli search 'type=pe and positives>=10'
30+
saferwall-cli search 'fs>2026 and tag=upx' --per-page 50
31+
saferwall-cli search 'extension=sys and positives>=10' --page 2`,
32+
Args: cobra.ExactArgs(1),
33+
Run: func(cmd *cobra.Command, args []string) {
34+
webSvc := webapi.New(cfg.Credentials.URL)
35+
result, err := webSvc.SearchFiles(args[0], cfg.Credentials.APIKey, searchPage, searchPerPage)
36+
if err != nil {
37+
log.Fatalf("search failed: %v", err)
38+
}
39+
printSearchResults(result, searchPage, searchPerPage)
40+
},
41+
}
42+
43+
func init() {
44+
searchCmd.Flags().IntVarP(&searchPage, "page", "p", 1, "Page number")
45+
searchCmd.Flags().IntVarP(&searchPerPage, "per-page", "n", 20, "Results per page")
46+
}
47+
48+
func printSearchResults(result *webapi.SearchResult, page, perPage int) {
49+
fmt.Println()
50+
51+
if result.TotalCount == 0 {
52+
fmt.Println(" No results found.")
53+
fmt.Println()
54+
return
55+
}
56+
57+
// Summary line.
58+
start := (page-1)*perPage + 1
59+
end := start + len(result.Items) - 1
60+
fmt.Printf(" %s\n\n",
61+
headerStyle.Render(fmt.Sprintf("Showing %d-%d of %d results", start, end, result.TotalCount)),
62+
)
63+
64+
// Column styles.
65+
nameCol := lipgloss.NewStyle().Width(24)
66+
typeCol := lipgloss.NewStyle().Width(16)
67+
sizeCol := lipgloss.NewStyle().Width(10)
68+
detCol := lipgloss.NewStyle().Width(12)
69+
dateCol := lipgloss.NewStyle().Width(12)
70+
clsCol := lipgloss.NewStyle().Width(12)
71+
72+
// Header row.
73+
fmt.Printf(" %s %s %s %s %s %s %s %s\n",
74+
styleDim.Render(fmt.Sprintf("%-64s", "SHA256")),
75+
styleDim.Render(nameCol.Render("NAME")),
76+
styleDim.Render(typeCol.Render("TYPE/EXT")),
77+
styleDim.Render(sizeCol.Render("SIZE")),
78+
styleDim.Render(detCol.Render("DETECTIONS")),
79+
styleDim.Render(dateCol.Render("FIRST SEEN")),
80+
styleDim.Render(dateCol.Render("LAST SCANNED")),
81+
styleDim.Render(clsCol.Render("VERDICT")),
82+
)
83+
fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 172)))
84+
85+
for _, item := range result.Items {
86+
// Name: hide if it looks like a bare hash (the API echoes the ID as name).
87+
name := item.Name
88+
if name == "" || looksLikeHash(name) {
89+
name = "-"
90+
}
91+
if len(name) > 24 {
92+
name = name[:21] + "..."
93+
}
94+
95+
// Type/extension column.
96+
typeStr := item.Format
97+
if item.Extension != "" {
98+
typeStr += "/" + item.Extension
99+
}
100+
if typeStr == "" {
101+
typeStr = "-"
102+
}
103+
if len(typeStr) > 16 {
104+
typeStr = typeStr[:13] + "..."
105+
}
106+
107+
// AV detections from condensed multiav.hits/total.
108+
detStr := "-"
109+
if item.MultiAV.Total > 0 {
110+
raw := fmt.Sprintf("%d/%d", item.MultiAV.Hits, item.MultiAV.Total)
111+
if item.MultiAV.Hits > 0 {
112+
detStr = detectStyle.Render(raw)
113+
} else {
114+
detStr = cleanStyle.Render(raw)
115+
}
116+
}
117+
118+
// Timestamps: date only.
119+
firstSeen := "-"
120+
if item.FirstSeen != 0 {
121+
firstSeen = time.Unix(item.FirstSeen, 0).UTC().Format("2006-01-02")
122+
}
123+
lastScanned := "-"
124+
if item.LastScanned != 0 {
125+
lastScanned = time.Unix(item.LastScanned, 0).UTC().Format("2006-01-02")
126+
}
127+
128+
fmt.Printf(" %s %s %s %s %s %s %s %s\n",
129+
item.ID,
130+
nameCol.Render(name),
131+
typeCol.Render(typeStr),
132+
sizeCol.Render(formatSize(item.Size)),
133+
detCol.Render(detStr),
134+
dateCol.Render(firstSeen),
135+
dateCol.Render(lastScanned),
136+
clsCol.Render(renderClassification(item.Classification)),
137+
)
138+
}
139+
140+
fmt.Println()
141+
142+
// Pagination hint.
143+
if result.PageCount > 1 {
144+
fmt.Printf(" %s\n\n",
145+
styleDim.Render(fmt.Sprintf("Page %d of %d — use --page to navigate", page, result.PageCount)),
146+
)
147+
}
148+
}
149+

internal/webapi/files.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,80 @@ func (s Service) Delete(sha256, authToken string) error {
300300
defer resp.Body.Close()
301301
return nil
302302
}
303+
304+
// SearchItem is the flattened file representation returned by the search endpoint.
305+
type SearchItem struct {
306+
ID string `json:"id"`
307+
Name string `json:"name"`
308+
Format string `json:"file_format"`
309+
Extension string `json:"file_extension"`
310+
Size int64 `json:"size"`
311+
FirstSeen int64 `json:"first_seen"`
312+
LastScanned int64 `json:"last_scanned"`
313+
Classification string `json:"class"`
314+
MultiAV SearchMultiAV `json:"multiav"`
315+
Tags map[string]any `json:"tags"`
316+
}
317+
318+
// SearchMultiAV holds the condensed AV stats returned in search results.
319+
type SearchMultiAV struct {
320+
Hits int `json:"hits"`
321+
Total int `json:"total"`
322+
}
323+
324+
// SearchResult is the paginated response from the search endpoint.
325+
type SearchResult struct {
326+
Page int `json:"page"`
327+
PerPage int `json:"per_page"`
328+
PageCount int `json:"page_count"`
329+
TotalCount int `json:"total_count"`
330+
Items []SearchItem `json:"items"`
331+
}
332+
333+
// SearchFiles calls POST /v1/files/search/ with the given query expression.
334+
func (s Service) SearchFiles(query, authToken string, page, perPage int) (*SearchResult, error) {
335+
url := s.filesURL + "search/"
336+
requestBody, err := json.Marshal(map[string]any{
337+
"query": query,
338+
"page": page,
339+
"per_page": perPage,
340+
})
341+
if err != nil {
342+
return nil, err
343+
}
344+
345+
request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
346+
if err != nil {
347+
return nil, err
348+
}
349+
request.Header.Set("Content-Type", "application/json")
350+
request.Header.Set("X-Api-Key", authToken)
351+
352+
resp, err := s.client.Do(request)
353+
if err != nil {
354+
return nil, err
355+
}
356+
357+
body := &bytes.Buffer{}
358+
_, err = body.ReadFrom(resp.Body)
359+
resp.Body.Close()
360+
if err != nil {
361+
return nil, err
362+
}
363+
364+
if resp.StatusCode != http.StatusOK {
365+
var jsonBody map[string]any
366+
if jsonErr := json.Unmarshal(body.Bytes(), &jsonBody); jsonErr == nil {
367+
if msg, ok := jsonBody["message"].(string); ok {
368+
return nil, errors.New(msg)
369+
}
370+
}
371+
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
372+
}
373+
374+
var result SearchResult
375+
if err := json.Unmarshal(body.Bytes(), &result); err != nil {
376+
return nil, err
377+
}
378+
return &result, nil
379+
}

0 commit comments

Comments
 (0)