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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
40 changes: 20 additions & 20 deletions cli.go
Original file line number Diff line number Diff line change
@@ -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()
}
6 changes: 3 additions & 3 deletions cmd/rescan.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ func init() {
reScanCmd.Flags().IntVar(&parallelFlag, "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.
Expand Down
6 changes: 3 additions & 3 deletions cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ func init() {
scanCmd.Flags().IntVarP(&parallelFlag, "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 {
Expand Down
94 changes: 63 additions & 31 deletions cmd/scanui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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) ---
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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),
}
}
}
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -624,4 +657,3 @@ func renderEncryptionStatus(s *scanSummary) string {
}
return out
}

1 change: 0 additions & 1 deletion cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,3 @@ func printSearchResults(result *webapi.SearchResult, page, perPage int) {
)
}
}

70 changes: 35 additions & 35 deletions internal/entity/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/entity/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ type User struct {
LikesCount int `json:"likes_count"`
SubmissionsCount int `json:"submissions_count"`
CommentsCount int `json:"comments_count"`
}
}
Loading