Skip to content

Commit cb15aeb

Browse files
authored
feat: add a new command to view scan results (#18)
Co-authored-by: Ayoub Faouzi <ayoubfaouzi@users.noreply.github.com>
1 parent 0abc49e commit cb15aeb

2 files changed

Lines changed: 305 additions & 0 deletions

File tree

cmd/view.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
"sort"
11+
"strings"
12+
"time"
13+
14+
"github.com/charmbracelet/lipgloss"
15+
"github.com/saferwall/cli/internal/entity"
16+
"github.com/saferwall/cli/internal/webapi"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
var viewCmd = &cobra.Command{
21+
Use: "view <sha256>",
22+
Short: "View scan results for a file by its SHA256 hash",
23+
Long: `Fetches and displays the scan results summary for a file, including AV detections.`,
24+
Args: cobra.ExactArgs(1),
25+
Run: func(cmd *cobra.Command, args []string) {
26+
sha256 := strings.ToLower(args[0])
27+
28+
webSvc := webapi.New(cfg.Credentials.URL)
29+
_, err := webSvc.Login(cfg.Credentials.Username, cfg.Credentials.Password)
30+
if err != nil {
31+
log.Fatalf("failed to login: %v", err)
32+
}
33+
34+
var file entity.File
35+
if err := webSvc.GetFile(sha256, &file); err != nil {
36+
log.Fatalf("failed to get file: %v", err)
37+
}
38+
39+
printFileReport(file, webSvc)
40+
},
41+
}
42+
43+
func init() {
44+
rootCmd.AddCommand(viewCmd)
45+
}
46+
47+
// Styles for the report output.
48+
var (
49+
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
50+
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
51+
keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
52+
detectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
53+
cleanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
54+
avNameStyle = lipgloss.NewStyle().Width(24)
55+
)
56+
57+
func printFileReport(file entity.File, webSvc webapi.Service) {
58+
fmt.Println()
59+
fmt.Println(titleStyle.Render("File Report"))
60+
fmt.Println(strings.Repeat("─", 60))
61+
62+
// File identification.
63+
fmt.Println(headerStyle.Render("Identification"))
64+
printKV("SHA256", file.SHA256)
65+
if !file.IsArchive {
66+
printKV("MD5", file.MD5)
67+
printKV("SHA1", file.SHA1)
68+
if file.SSDeep != "" {
69+
printKV("SSDeep", file.SSDeep)
70+
}
71+
}
72+
printKV("Size", formatSize(file.Size))
73+
fmt.Println()
74+
75+
// File properties.
76+
fmt.Println(headerStyle.Render("Properties"))
77+
if file.Magic != "" {
78+
printKV("Magic", file.Magic)
79+
}
80+
if file.Format != "" {
81+
fmtStr := file.Format
82+
if file.Extension != "" {
83+
fmtStr += " (." + file.Extension + ")"
84+
}
85+
printKV("Format", fmtStr)
86+
}
87+
if !file.IsArchive && len(file.Packer) > 0 {
88+
printKV("Packer", strings.Join(file.Packer, ", "))
89+
}
90+
if file.IsArchive {
91+
printKV("Archive", fmt.Sprintf("yes (%d files)", len(file.ArchiveFiles)))
92+
}
93+
if file.ArchiveSHA256 != "" {
94+
printKV("Parent", file.ArchiveSHA256)
95+
}
96+
if file.FirstSeen != 0 {
97+
printKV("First Seen", formatTimestamp(file.FirstSeen))
98+
}
99+
if file.LastScanned != 0 {
100+
printKV("Last Scanned", formatTimestamp(file.LastScanned))
101+
}
102+
fmt.Println()
103+
104+
if file.IsArchive {
105+
// Archives only scan their children, skip verdict and AV results.
106+
if len(file.ArchiveFiles) > 0 {
107+
printArchiveChildren(file.ArchiveFiles, webSvc)
108+
}
109+
} else {
110+
// Classification.
111+
fmt.Println(headerStyle.Render("Classification"))
112+
printKV("Verdict", renderClassification(file.Classification))
113+
fmt.Println()
114+
115+
// MultiAV results.
116+
printMultiAVResults(file.MultiAV)
117+
}
118+
}
119+
120+
// childSummary holds the minimal info we display per archive child.
121+
type childSummary struct {
122+
sha256 string
123+
classification string
124+
format string
125+
positives int
126+
enginesCount int
127+
err error
128+
}
129+
130+
func fetchChildSummary(sha256 string, webSvc webapi.Service) childSummary {
131+
var file entity.File
132+
if err := webSvc.GetFile(sha256, &file); err != nil {
133+
return childSummary{sha256: sha256, err: err}
134+
}
135+
cs := childSummary{
136+
sha256: sha256,
137+
classification: file.Classification,
138+
format: file.Format,
139+
}
140+
if file.Extension != "" {
141+
cs.format += "/" + file.Extension
142+
}
143+
if lastScan, ok := file.MultiAV["last_scan"].(map[string]any); ok {
144+
if stats, ok := lastScan["stats"].(map[string]any); ok {
145+
if v, ok := stats["positives"].(float64); ok {
146+
cs.positives = int(v)
147+
}
148+
if v, ok := stats["engines_count"].(float64); ok {
149+
cs.enginesCount = int(v)
150+
}
151+
}
152+
}
153+
return cs
154+
}
155+
156+
func printArchiveChildren(archiveFiles []string, webSvc webapi.Service) {
157+
fmt.Println(headerStyle.Render(fmt.Sprintf("Archive Contents (%d files)", len(archiveFiles))))
158+
fmt.Println()
159+
160+
// Table header.
161+
fmtCol := lipgloss.NewStyle().Width(16)
162+
avCol := lipgloss.NewStyle().Width(14)
163+
clsCol := lipgloss.NewStyle().Width(12)
164+
165+
fmt.Printf(" %s %s %s %s\n",
166+
styleDim.Render(fmt.Sprintf("%-64s", "SHA256")),
167+
styleDim.Render(fmtCol.Render("FORMAT")),
168+
styleDim.Render(avCol.Render("DETECTIONS")),
169+
styleDim.Render(clsCol.Render("VERDICT")),
170+
)
171+
fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 108)))
172+
173+
for _, sha := range archiveFiles {
174+
cs := fetchChildSummary(sha, webSvc)
175+
if cs.err != nil {
176+
fmt.Printf(" %s %s\n",
177+
sha,
178+
styleError.Render("error: "+cs.err.Error()),
179+
)
180+
continue
181+
}
182+
183+
detStr := fmt.Sprintf("%d/%d", cs.positives, cs.enginesCount)
184+
if cs.positives > 0 {
185+
detStr = detectStyle.Render(detStr)
186+
} else {
187+
detStr = cleanStyle.Render(detStr)
188+
}
189+
190+
fmt.Printf(" %s %s %s %s\n",
191+
cs.sha256,
192+
fmtCol.Render(cs.format),
193+
avCol.Render(detStr),
194+
clsCol.Render(renderClassification(cs.classification)),
195+
)
196+
}
197+
fmt.Println()
198+
}
199+
200+
func printMultiAVResults(multiav map[string]any) {
201+
if multiav == nil {
202+
fmt.Println(headerStyle.Render("Antivirus Results"))
203+
fmt.Println(" No scan results available.")
204+
return
205+
}
206+
207+
lastScan, ok := multiav["last_scan"].(map[string]any)
208+
if !ok {
209+
fmt.Println(headerStyle.Render("Antivirus Results"))
210+
fmt.Println(" No scan results available.")
211+
return
212+
}
213+
214+
// Extract stats.
215+
var positives, enginesCount int
216+
if stats, ok := lastScan["stats"].(map[string]any); ok {
217+
if v, ok := stats["positives"].(float64); ok {
218+
positives = int(v)
219+
}
220+
if v, ok := stats["engines_count"].(float64); ok {
221+
enginesCount = int(v)
222+
}
223+
}
224+
225+
// Summary line.
226+
fmt.Println(headerStyle.Render("Antivirus Results"))
227+
detectionStr := fmt.Sprintf("%d/%d engines detected this file", positives, enginesCount)
228+
if positives > 0 {
229+
fmt.Println(" " + detectStyle.Render(detectionStr))
230+
} else {
231+
fmt.Println(" " + cleanStyle.Render(detectionStr))
232+
}
233+
fmt.Println()
234+
235+
// Collect detected engines only (engines live under last_scan.detections).
236+
type avResult struct {
237+
name string
238+
output string
239+
}
240+
var detected []avResult
241+
var clean []avResult
242+
243+
detections, _ := lastScan["detections"].(map[string]any)
244+
for key, val := range detections {
245+
engine, ok := val.(map[string]any)
246+
if !ok {
247+
continue
248+
}
249+
250+
infected, _ := engine["infected"].(bool)
251+
output, _ := engine["output"].(string)
252+
if infected {
253+
detected = append(detected, avResult{name: key, output: output})
254+
} else {
255+
clean = append(clean, avResult{name: key})
256+
}
257+
}
258+
259+
sort.Slice(detected, func(i, j int) bool { return detected[i].name < detected[j].name })
260+
sort.Slice(clean, func(i, j int) bool { return clean[i].name < clean[j].name })
261+
262+
// Print detections.
263+
if len(detected) > 0 {
264+
for _, r := range detected {
265+
name := avNameStyle.Render(r.name)
266+
fmt.Printf(" %s %s\n", detectStyle.Render("●")+" "+name, detectStyle.Render(r.output))
267+
}
268+
fmt.Println()
269+
}
270+
271+
// Print clean engines.
272+
if len(clean) > 0 {
273+
cleanNames := make([]string, len(clean))
274+
for i, r := range clean {
275+
cleanNames[i] = r.name
276+
}
277+
fmt.Printf(" %s %s\n", cleanStyle.Render("○"), styleDim.Render("No detection: "+strings.Join(cleanNames, ", ")))
278+
fmt.Println()
279+
}
280+
}
281+
282+
func printKV(key, value string) {
283+
fmt.Printf(" %s %s\n", keyStyle.Render(fmt.Sprintf("%-14s", key+":")), value)
284+
}
285+
286+
func formatSize(size int64) string {
287+
switch {
288+
case size >= 1<<30:
289+
return fmt.Sprintf("%.2f GB (%d bytes)", float64(size)/float64(1<<30), size)
290+
case size >= 1<<20:
291+
return fmt.Sprintf("%.2f MB (%d bytes)", float64(size)/float64(1<<20), size)
292+
case size >= 1<<10:
293+
return fmt.Sprintf("%.2f KB (%d bytes)", float64(size)/float64(1<<10), size)
294+
default:
295+
return fmt.Sprintf("%d bytes", size)
296+
}
297+
}
298+
299+
func formatTimestamp(ts int64) string {
300+
t := time.Unix(ts, 0)
301+
return t.Format("2006-01-02 15:04:05 UTC")
302+
}

internal/entity/file.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ type File struct {
3434
BehaviorReportID string `json:"behavior_report_id,omitempty"`
3535
Status int `json:"status,omitempty"`
3636
Classification string `json:"classification,omitempty"`
37+
IsArchive bool `json:"is_archive,omitempty"`
38+
ArchiveFiles []string `json:"archive_files,omitempty"`
39+
ArchiveSHA256 string `json:"archive_sha256,omitempty"`
3740
}
3841

3942
// Submission represents a file submission.

0 commit comments

Comments
 (0)