From 79ead012b9ab1809cd7a755fb5ea7cd9964660e0 Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 07:09:14 +0100 Subject: [PATCH 1/3] feat: skip detonation by default, fix rescan body and OS values - Clarify that -d/--enableDetonation opts into sandbox detonation; skip_detonation=true is sent by default (help text said the opposite) - Flatten the rescan JSON body: the backend binds FileScanRequest directly, so os/timeout nested under scan_cfg were silently ignored - Use the platform's canonical OS identifier windows-10-x64 (was win-10, which the backend accepts but the web UI doesn't recognize) --- README.md | 4 ++-- cmd/rescan.go | 6 +++--- cmd/scan.go | 6 +++--- internal/webapi/files.go | 26 ++++++++++++-------------- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1b97066..6663752 100755 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ saferwall-cli scan -p 4 /path/to/directory saferwall-cli scan -f /path/to/sample # Enable detonation with custom timeout and OS -saferwall-cli scan -d -t 30 -o win-7 /path/to/sample +saferwall-cli scan -d -t 30 -o windows-10-x64 /path/to/sample ``` | Flag | Short | Default | Description | @@ -66,7 +66,7 @@ saferwall-cli scan -d -t 30 -o win-7 /path/to/sample | `--parallel` | `-p` | `1` | Number of files to scan in parallel | | `--enableDetonation` | `-d` | `false` | Enable detonation (dynamic analysis) | | `--timeout` | `-t` | `15` | Detonation duration in seconds | -| `--os` | `-o` | `win-10` | Preferred OS for detonation (`win-7` or `win-10`) | +| `--os` | `-o` | `windows-10-x64` | Preferred OS for detonation (`windows-7-x64`, `windows-10-x64`, or `windows-11-x64`) | ### Rescan diff --git a/cmd/rescan.go b/cmd/rescan.go index 2c6862c..ddd29b2 100644 --- a/cmd/rescan.go +++ b/cmd/rescan.go @@ -22,11 +22,11 @@ func init() { reScanCmd.Flags().IntVar(¶llelFlag, "parallel", 1, "Number of files to rescan in parallel") reScanCmd.Flags().BoolVarP(&enableDetonationFlag, "enableDetonation", "d", false, - "Skip detonation") + "Enable sandbox detonation (skipped by default)") reScanCmd.Flags().IntVarP(&timeoutFlag, "timeout", "t", 15, "Detonation duration in seconds") - reScanCmd.Flags().StringVarP(&osFlag, "os", "o", "win-10", - "Preferred OS for detonation, choice(win-7 | win-10)") + reScanCmd.Flags().StringVarP(&osFlag, "os", "o", "windows-10-x64", + "Preferred OS for detonation, choice(windows-7-x64 | windows-10-x64 | windows-11-x64)") } // reScanFile re-scans a list of SHA256 with a TUI progress display. diff --git a/cmd/scan.go b/cmd/scan.go index 4575f89..8325827 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -38,11 +38,11 @@ func init() { scanCmd.Flags().IntVarP(¶llelFlag, "parallel", "p", 1, "Number of files to scan in parallel") scanCmd.Flags().BoolVarP(&enableDetonationFlag, "enableDetonation", "d", false, - "Skip detonation") + "Enable sandbox detonation (skipped by default)") scanCmd.Flags().IntVarP(&timeoutFlag, "timeout", "t", 15, "Detonation duration in seconds") - scanCmd.Flags().StringVarP(&osFlag, "os", "o", "win-10", - "Preferred OS for detonation, choice(win-7 | win-10)") + scanCmd.Flags().StringVarP(&osFlag, "os", "o", "windows-10-x64", + "Preferred OS for detonation, choice(windows-7-x64 | windows-10-x64 | windows-11-x64)") } type scanSummary struct { diff --git a/internal/webapi/files.go b/internal/webapi/files.go index 2cf9f7a..b0c5e33 100644 --- a/internal/webapi/files.go +++ b/internal/webapi/files.go @@ -162,10 +162,8 @@ func (s Service) Rescan(sha256, authToken, preferredOS string, enableDetonation requestBody, err := json.Marshal(map[string]any{ "skip_detonation": !enableDetonation, - "scan_cfg": map[string]any{ - "os": preferredOS, - "timeout": timeout, - }, + "os": preferredOS, + "timeout": timeout, }) if err != nil { return err @@ -303,16 +301,16 @@ func (s Service) Delete(sha256, authToken string) error { // 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"` + 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. From 3f03845e34546da0c67d5c034a2b6edeea253f5b Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 07:09:19 +0100 Subject: [PATCH 2/3] fix: deduplicate identical files extracted from archives The backend records every archive entry in derived_files but only scans each unique hash once. The CLI created one polling row per entry and, on rescan paths, submitted duplicate hashes twice. Dedupe children by SHA256 before rescanning/polling and show the duplicate count in the archive row. --- cmd/scanui.go | 94 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/cmd/scanui.go b/cmd/scanui.go index 4c3c639..7abbf86 100644 --- a/cmd/scanui.go +++ b/cmd/scanui.go @@ -43,7 +43,8 @@ type fileRow struct { err error pollCount int isArchive bool // true for ZIP containers with multiple files - childCount int // number of extracted files + childCount int // number of unique extracted files + childDupes int // extracted entries sharing the hash of another child } // Top-level bubbletea model. @@ -59,12 +60,13 @@ type scanModel struct { // --- Messages --- type fileUploadedMsg struct { - index int - sha256 string - size int64 - err error - isArchive bool - children []entity.DerivedFile + index int + sha256 string + size int64 + err error + isArchive bool + children []entity.DerivedFile + childDupes int // archive entries skipped because they duplicate another child's hash } type fileScanStatusMsg struct { @@ -74,11 +76,12 @@ type fileScanStatusMsg struct { } type fileScanDoneMsg struct { - index int - summary scanSummary - isArchive bool - children []entity.DerivedFile - err error + index int + summary scanSummary + isArchive bool + children []entity.DerivedFile + childDupes int // archive entries skipped because they duplicate another child's hash + err error } // --- Commands (async I/O) --- @@ -117,18 +120,20 @@ func uploadFileCmd(index int, web webapi.Service, filename, token string) tea.Cm } if file.IsArchive && len(file.DerivedFiles) > 0 { - // Archive: rescan each child, not the container itself. - for _, df := range file.DerivedFiles { + // Archive: rescan each unique child, not the container itself. + children := uniqueDerivedFiles(file.DerivedFiles) + for _, df := range children { if err := web.Rescan(df.SHA256, token, osFlag, enableDetonationFlag, timeoutFlag); err != nil { return fileUploadedMsg{index: index, err: fmt.Errorf("rescan child %s: %w", df.SHA256[:12], err)} } } return fileUploadedMsg{ - index: index, - sha256: sha256, - size: file.Size, - isArchive: true, - children: file.DerivedFiles, + index: index, + sha256: sha256, + size: file.Size, + isArchive: true, + children: children, + childDupes: len(file.DerivedFiles) - len(children), } } @@ -158,11 +163,13 @@ func fetchResultCmd(index int, web webapi.Service, sha256 string) tea.Cmd { if err := web.GetFile(sha256, &file); err != nil { return fileScanDoneMsg{index: index, err: fmt.Errorf("get file report: %w", err)} } + children := uniqueDerivedFiles(file.DerivedFiles) return fileScanDoneMsg{ - index: index, - summary: buildScanSummary(file), - isArchive: file.IsArchive, - children: file.DerivedFiles, + index: index, + summary: buildScanSummary(file), + isArchive: file.IsArchive, + children: children, + childDupes: len(file.DerivedFiles) - len(children), } } } @@ -186,17 +193,19 @@ func rescanFileCmd(index int, web webapi.Service, sha256, token string) tea.Cmd } if file.IsArchive && len(file.DerivedFiles) > 0 { - for _, df := range file.DerivedFiles { + children := uniqueDerivedFiles(file.DerivedFiles) + for _, df := range children { if err := web.Rescan(df.SHA256, token, osFlag, enableDetonationFlag, timeoutFlag); err != nil { return fileUploadedMsg{index: index, err: fmt.Errorf("rescan child %s: %w", df.SHA256[:12], err)} } } return fileUploadedMsg{ - index: index, - sha256: sha256, - size: file.Size, - isArchive: true, - children: file.DerivedFiles, + index: index, + sha256: sha256, + size: file.Size, + isArchive: true, + children: children, + childDupes: len(file.DerivedFiles) - len(children), } } @@ -315,6 +324,7 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.files[i].state = stateScanning m.files[i].isArchive = true m.files[i].childCount = len(msg.children) + m.files[i].childDupes = msg.childDupes m.files[i].size = msg.size cmds = append(cmds, pollStatusCmd(i, m.web, msg.sha256)) @@ -374,6 +384,7 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.isArchive && !m.files[i].isArchive && len(msg.children) > 0 { m.files[i].isArchive = true m.files[i].childCount = len(msg.children) + m.files[i].childDupes = msg.childDupes archiveName := filepath.Base(m.files[i].filename) for _, df := range msg.children { s := spinner.New() @@ -519,7 +530,12 @@ func (m scanModel) View() string { size = f.result.Size } line += " " + styleDim.Render(formatSize(size)) - line += " " + styleLabel.Render(fmt.Sprintf("archive (%d files)", f.childCount)) + archiveLabel := fmt.Sprintf("archive (%d files)", f.childCount) + if f.childDupes > 0 { + archiveLabel = fmt.Sprintf("archive (%d files, %d duplicates)", + f.childCount+f.childDupes, f.childDupes) + } + line += " " + styleLabel.Render(archiveLabel) } else if f.result != nil { line += " " + styleDim.Render(formatSize(f.result.Size)) fmtStr := f.result.FileFormat @@ -607,6 +623,23 @@ func childDisplayName(df entity.DerivedFile) string { return truncSha(df.SHA256) } +// uniqueDerivedFiles deduplicates derived files by SHA256, keeping the first +// occurrence. An archive can contain identical files under different names; +// the backend only scans each unique hash once, so tracking duplicates would +// poll (or rescan) the same file twice. +func uniqueDerivedFiles(dfs []entity.DerivedFile) []entity.DerivedFile { + seen := make(map[string]struct{}, len(dfs)) + var unique []entity.DerivedFile + for _, df := range dfs { + if _, dup := seen[df.SHA256]; dup { + continue + } + seen[df.SHA256] = struct{}{} + unique = append(unique, df) + } + return unique +} + func renderEncryptionStatus(s *scanSummary) string { if s.DecryptionSuccess == nil { return " " + styleWarning.Render("encrypted") @@ -624,4 +657,3 @@ func renderEncryptionStatus(s *scanSummary) string { } return out } - From f5252295f5a3e66e6ec869cf0a870a8ffb54760b Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 07:09:19 +0100 Subject: [PATCH 3/3] style: gofmt --- cli.go | 40 +++--- cmd/search.go | 1 - internal/entity/file.go | 70 +++++----- internal/entity/user.go | 2 +- internal/util/utils.go | 288 ++++++++++++++++++++-------------------- 5 files changed, 200 insertions(+), 201 deletions(-) diff --git a/cli.go b/cli.go index 7d1e483..a8a54f1 100755 --- a/cli.go +++ b/cli.go @@ -1,20 +1,20 @@ -// 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 main - -import ( - "github.com/saferwall/cli/cmd" -) - -var ( - version = "dev" - commit = "none" - date = "unknown" -) - -func main() { - cmd.SetVersionInfo(version, commit, date) - cmd.Execute() -} +// 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 main + +import ( + "github.com/saferwall/cli/cmd" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + cmd.SetVersionInfo(version, commit, date) + cmd.Execute() +} diff --git a/cmd/search.go b/cmd/search.go index d679075..aed663e 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -146,4 +146,3 @@ func printSearchResults(result *webapi.SearchResult, page, perPage int) { ) } } - diff --git a/internal/entity/file.go b/internal/entity/file.go index ffd30a7..08ec645 100755 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -6,41 +6,41 @@ package entity // File represent a sample type File struct { - Type string `json:"type,omitempty"` - MD5 string `json:"md5,omitempty"` - SHA1 string `json:"sha1,omitempty"` - SHA256 string `json:"sha256,omitempty"` - SHA512 string `json:"sha512,omitempty"` - SSDeep string `json:"ssdeep,omitempty"` - Crc32 string `json:"crc32,omitempty"` - Size int64 `json:"size,omitempty"` - Tags map[string]any `json:"tags,omitempty"` - Magic string `json:"magic,omitempty"` - Exif map[string]string `json:"exif,omitempty"` - TriD []string `json:"trid,omitempty"` - Packer []string `json:"packer,omitempty"` - FirstSeen int64 `json:"first_seen,omitempty"` - LastScanned int64 `json:"last_scanned,omitempty"` - Submissions []Submission `json:"submissions,omitempty"` - Strings any `json:"strings,omitempty"` - MultiAV map[string]any `json:"multiav,omitempty"` - PE any `json:"pe,omitempty"` - Histogram []int `json:"histogram,omitempty"` - ByteEntropy []int `json:"byte_entropy,omitempty"` - Ml map[string]any `json:"ml,omitempty"` - CommentsCount *int `json:"comments_count,omitempty"` - Format string `json:"file_format,omitempty"` - Extension string `json:"file_extension,omitempty"` - BehaviorReportID string `json:"behavior_report_id,omitempty"` - Status int `json:"status,omitempty"` - Classification string `json:"classification,omitempty"` - IsArchive bool `json:"is_archive,omitempty"` - DerivedFiles []DerivedFile `json:"derived_files,omitempty"` - ParentSHA256 string `json:"parent_sha256,omitempty"` - Encrypted bool `json:"encrypted"` - DecryptionSuccess *bool `json:"decryption_success,omitempty"` - SuccessfulPassword string `json:"successful_password,omitempty"` - AttemptedPasswords []string `json:"attempted_passwords,omitempty"` + Type string `json:"type,omitempty"` + MD5 string `json:"md5,omitempty"` + SHA1 string `json:"sha1,omitempty"` + SHA256 string `json:"sha256,omitempty"` + SHA512 string `json:"sha512,omitempty"` + SSDeep string `json:"ssdeep,omitempty"` + Crc32 string `json:"crc32,omitempty"` + Size int64 `json:"size,omitempty"` + Tags map[string]any `json:"tags,omitempty"` + Magic string `json:"magic,omitempty"` + Exif map[string]string `json:"exif,omitempty"` + TriD []string `json:"trid,omitempty"` + Packer []string `json:"packer,omitempty"` + FirstSeen int64 `json:"first_seen,omitempty"` + LastScanned int64 `json:"last_scanned,omitempty"` + Submissions []Submission `json:"submissions,omitempty"` + Strings any `json:"strings,omitempty"` + MultiAV map[string]any `json:"multiav,omitempty"` + PE any `json:"pe,omitempty"` + Histogram []int `json:"histogram,omitempty"` + ByteEntropy []int `json:"byte_entropy,omitempty"` + Ml map[string]any `json:"ml,omitempty"` + CommentsCount *int `json:"comments_count,omitempty"` + Format string `json:"file_format,omitempty"` + Extension string `json:"file_extension,omitempty"` + BehaviorReportID string `json:"behavior_report_id,omitempty"` + Status int `json:"status,omitempty"` + Classification string `json:"classification,omitempty"` + IsArchive bool `json:"is_archive,omitempty"` + DerivedFiles []DerivedFile `json:"derived_files,omitempty"` + ParentSHA256 string `json:"parent_sha256,omitempty"` + Encrypted bool `json:"encrypted"` + DecryptionSuccess *bool `json:"decryption_success,omitempty"` + SuccessfulPassword string `json:"successful_password,omitempty"` + AttemptedPasswords []string `json:"attempted_passwords,omitempty"` } // DerivedFile is a child file produced during analysis of a parent — either a diff --git a/internal/entity/user.go b/internal/entity/user.go index 987d722..a425646 100644 --- a/internal/entity/user.go +++ b/internal/entity/user.go @@ -27,4 +27,4 @@ type User struct { LikesCount int `json:"likes_count"` SubmissionsCount int `json:"submissions_count"` CommentsCount int `json:"comments_count"` -} \ No newline at end of file +} diff --git a/internal/util/utils.go b/internal/util/utils.go index 636c5a9..d99edba 100755 --- a/internal/util/utils.go +++ b/internal/util/utils.go @@ -1,144 +1,144 @@ -// Copyright 2022 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 util - -import ( - "crypto/sha256" - "encoding/hex" - "io" - "os" - "path/filepath" - "runtime" -) - -// GetSha256 returns SHA256 hash. -func GetSha256(b []byte) string { - h := sha256.New() - h.Write(b) - return hex.EncodeToString(h.Sum(nil)) -} - -// ReadAll reads the entire file into memory. -func ReadAll(filePath string) ([]byte, error) { - // Start by getting a file descriptor over the file - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer file.Close() - - // Get the file size to know how much we need to allocate - fileinfo, err := file.Stat() - if err != nil { - return nil, err - } - filesize := fileinfo.Size() - buffer := make([]byte, filesize) - - // Read the whole binary - _, err = file.Read(buffer) - if err != nil { - return nil, err - } - return buffer, nil -} - -// WriteBytesFile write Bytes to a File. -func WriteBytesFile(filename string, r io.Reader) (int, error) { - - // Open a new file for writing only - file, err := os.OpenFile( - filename, - os.O_WRONLY|os.O_TRUNC|os.O_CREATE, - 0666, - ) - if err != nil { - return 0, err - } - defer file.Close() - - b, err := io.ReadAll(r) - if err != nil { - return 0, err - } - - // Write bytes to disk - bytesWritten, err := file.Write(b) - if err != nil { - return 0, err - } - - return bytesWritten, nil -} - -// Exists reports whether the named file or directory exists. -func Exists(name string) bool { - if _, err := os.Stat(name); err != nil { - if os.IsNotExist(err) { - return false - } - } - return true -} - -// MkDir create a directory if it does not exists. -func MkDir(name string) bool { - if !Exists(name) { - return os.Mkdir(name, 0755) == nil - } - return true -} - -// StringInSlice returns whether or not a string exists in a slice. -func StringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} - -// UniqueSlice delete duplicate strings from an array of strings. -func UniqueSlice(slice []string) []string { - cleaned := []string{} - - for _, value := range slice { - if !StringInSlice(value, cleaned) { - cleaned = append(cleaned, value) - } - } - return cleaned -} - -// WalkAllFilesInDir returns list of files in directory. -func WalkAllFilesInDir(dir string) ([]string, error) { - - fileList := []string{} - err := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error { - if e != nil { - return e - } - - // check if it is a regular file (not dir) - if info.Mode().IsRegular() { - fileList = append(fileList, path) - } - return nil - }) - - return fileList, err -} - -func UserHomeDir() string { - if runtime.GOOS == "windows" { - home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") - if home == "" { - home = os.Getenv("USERPROFILE") - } - return home - } - return os.Getenv("HOME") -} +// Copyright 2022 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 util + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "runtime" +) + +// GetSha256 returns SHA256 hash. +func GetSha256(b []byte) string { + h := sha256.New() + h.Write(b) + return hex.EncodeToString(h.Sum(nil)) +} + +// ReadAll reads the entire file into memory. +func ReadAll(filePath string) ([]byte, error) { + // Start by getting a file descriptor over the file + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + // Get the file size to know how much we need to allocate + fileinfo, err := file.Stat() + if err != nil { + return nil, err + } + filesize := fileinfo.Size() + buffer := make([]byte, filesize) + + // Read the whole binary + _, err = file.Read(buffer) + if err != nil { + return nil, err + } + return buffer, nil +} + +// WriteBytesFile write Bytes to a File. +func WriteBytesFile(filename string, r io.Reader) (int, error) { + + // Open a new file for writing only + file, err := os.OpenFile( + filename, + os.O_WRONLY|os.O_TRUNC|os.O_CREATE, + 0666, + ) + if err != nil { + return 0, err + } + defer file.Close() + + b, err := io.ReadAll(r) + if err != nil { + return 0, err + } + + // Write bytes to disk + bytesWritten, err := file.Write(b) + if err != nil { + return 0, err + } + + return bytesWritten, nil +} + +// Exists reports whether the named file or directory exists. +func Exists(name string) bool { + if _, err := os.Stat(name); err != nil { + if os.IsNotExist(err) { + return false + } + } + return true +} + +// MkDir create a directory if it does not exists. +func MkDir(name string) bool { + if !Exists(name) { + return os.Mkdir(name, 0755) == nil + } + return true +} + +// StringInSlice returns whether or not a string exists in a slice. +func StringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +// UniqueSlice delete duplicate strings from an array of strings. +func UniqueSlice(slice []string) []string { + cleaned := []string{} + + for _, value := range slice { + if !StringInSlice(value, cleaned) { + cleaned = append(cleaned, value) + } + } + return cleaned +} + +// WalkAllFilesInDir returns list of files in directory. +func WalkAllFilesInDir(dir string) ([]string, error) { + + fileList := []string{} + err := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error { + if e != nil { + return e + } + + // check if it is a regular file (not dir) + if info.Mode().IsRegular() { + fileList = append(fileList, path) + } + return nil + }) + + return fileList, err +} + +func UserHomeDir() string { + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return os.Getenv("HOME") +}