From a003e0397dcbff83ce873e848f9c4d4bdf7c7d8f Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Tue, 3 Mar 2026 15:17:54 +0000 Subject: [PATCH 1/4] update --- .golangci.yaml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .golangci.yaml diff --git a/.golangci.yaml b/.golangci.yaml deleted file mode 100644 index 0b125c3..0000000 --- a/.golangci.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Options for analysis running. -run: - # Exit code when at least one issue was found. - # Default: 1 - issues-exit-code: 0 - -# All available settings of specific linters. -linters-settings: - errcheck: - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. - # Such cases aren't reported by default. - # Default: false - check-blank: false From cc26ae7a851513bdbdc2a11c65a53cc96637389c Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Tue, 3 Mar 2026 15:22:00 +0000 Subject: [PATCH 2/4] fix download command --- README.md | 8 ++++++-- cmd/download.go | 49 +++++++++++++++++++++---------------------------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e97a1d9..2c35599 100755 --- a/README.md +++ b/README.md @@ -57,8 +57,12 @@ saferwall-cli rescan ### Download -Download files by their SHA256 hash. You can also download a batch of samples from a text file. +Download a sample by its SHA256 hash, or provide a text file with one hash per line to download in batch. ```sh -saferwall-cli download --hash +# Single sample +saferwall-cli download + +# Batch from a text file +saferwall-cli download hashes.txt ``` diff --git a/cmd/download.go b/cmd/download.go index 419bb0e..4bc174a 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -16,8 +16,6 @@ import ( "github.com/spf13/cobra" ) -var sha256Flag string -var txtFlag string var outputFlag string func init() { @@ -26,40 +24,41 @@ func init() { panic(err) } - downloadCmd.Flags().StringVarP(&sha256Flag, "hash", "s", "", "SHA256 hash to download") - downloadCmd.Flags().StringVarP(&txtFlag, "txt", "t", "", "Download all hashes in a text file, separate by a line break") downloadCmd.Flags().StringVarP(&outputFlag, "output", "o", filepath.Dir(ex), "Destination directory where to save samples. (default=current dir)") } var downloadCmd = &cobra.Command{ - Use: "download", - Short: "Download a sample(s)", - Long: `Download a binary sample given a sha256`, + Use: "download ", + Short: "Download a sample (and its artifacts)", + Long: `Download a binary sample given a SHA256 hash, or a batch of samples from a text file containing one hash per line.`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { + arg := args[0] - // Login to saferwall web service + // Login to saferwall web service. webSvc := webapi.New(cfg.Credentials.URL) token, err := webSvc.Login(cfg.Credentials.Username, cfg.Credentials.Password) if err != nil { log.Fatalf("failed to login to saferwall web service") } - // download a single binary. - if sha256Flag != "" { - download(sha256Flag, token, webSvc) - } else if txtFlag != "" { - // Download a list of sha256 hashes. - data, err := util.ReadAll(txtFlag) + if sha256Re.MatchString(arg) { + // Single hash: download directly. + if err := download(arg, token, webSvc); err != nil { + log.Fatalf("failed to download sample (%s): %v", arg, err) + } + } else { + // Treat as a text file of hashes. + data, err := util.ReadAll(arg) if err != nil { - log.Fatalf("failed to read to SHA256 hashes from txt file: %v", txtFlag) + log.Fatalf("failed to read SHA256 hashes from file: %s", arg) } - sha256list := strings.Split(string(data), "\n") - for _, sha256 := range sha256list { - if len(sha256) >= 64 { - err = download(sha256, token, webSvc) - if err != nil { + for _, sha256 := range strings.Split(string(data), "\n") { + sha256 = strings.TrimSpace(sha256) + if sha256Re.MatchString(sha256) { + if err := download(sha256, token, webSvc); err != nil { log.Fatalf("failed to download sample (%s): %v", sha256, err) } } @@ -69,9 +68,7 @@ var downloadCmd = &cobra.Command{ } func download(sha256, token string, web webapi.Service) error { - var err error var data bytes.Buffer - var destPath string log.Printf("downloading %s to %s", sha256, outputFlag) dataContent, err := web.Download(sha256, token) @@ -82,11 +79,7 @@ func download(sha256, token string, web webapi.Service) error { data = *dataContent filename := sha256 + ".zip" - destPath = filepath.Join(outputFlag, filename) + destPath := filepath.Join(outputFlag, filename) _, err = util.WriteBytesFile(destPath, &data) - if err != nil { - return err - } - - return nil + return err } From 051ae67c7b5d6757dfed245e7b22e19e40f20880 Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Tue, 3 Mar 2026 15:27:05 +0000 Subject: [PATCH 3/4] make download pretty --- cmd/download.go | 67 ++++++++------- cmd/downloadui.go | 206 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 cmd/downloadui.go diff --git a/cmd/download.go b/cmd/download.go index 4bc174a..757133c 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -5,12 +5,13 @@ package cmd import ( - "bytes" + "fmt" "log" "os" "path/filepath" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/saferwall/cli/internal/util" "github.com/saferwall/cli/internal/webapi" "github.com/spf13/cobra" @@ -26,6 +27,8 @@ func init() { downloadCmd.Flags().StringVarP(&outputFlag, "output", "o", filepath.Dir(ex), "Destination directory where to save samples. (default=current dir)") + downloadCmd.Flags().IntVarP(¶llelFlag, "parallel", "p", 4, + "Number of files to download in parallel") } var downloadCmd = &cobra.Command{ @@ -43,43 +46,43 @@ var downloadCmd = &cobra.Command{ log.Fatalf("failed to login to saferwall web service") } - if sha256Re.MatchString(arg) { - // Single hash: download directly. - if err := download(arg, token, webSvc); err != nil { - log.Fatalf("failed to download sample (%s): %v", arg, err) - } - } else { - // Treat as a text file of hashes. - data, err := util.ReadAll(arg) - if err != nil { - log.Fatalf("failed to read SHA256 hashes from file: %s", arg) - } - - for _, sha256 := range strings.Split(string(data), "\n") { - sha256 = strings.TrimSpace(sha256) - if sha256Re.MatchString(sha256) { - if err := download(sha256, token, webSvc); err != nil { - log.Fatalf("failed to download sample (%s): %v", sha256, err) - } - } - } + hashes := collectHashes(arg) + if len(hashes) == 0 { + log.Fatalf("no valid SHA256 hashes found in %q", arg) } + + downloadFiles(webSvc, token, hashes) }, } -func download(sha256, token string, web webapi.Service) error { - var data bytes.Buffer +// collectHashes returns a list of SHA256 hashes from the argument. +// If arg is a SHA256 hash, it returns a single-element slice. +// Otherwise it treats arg as a file path and reads hashes from it. +func collectHashes(arg string) []string { + if sha256Re.MatchString(arg) { + return []string{arg} + } - log.Printf("downloading %s to %s", sha256, outputFlag) - dataContent, err := web.Download(sha256, token) + data, err := util.ReadAll(arg) if err != nil { - log.Fatalf("failed to download %s, err: %v", sha256, err) - return err + log.Fatalf("failed to read SHA256 hashes from file: %s", arg) + } + + var hashes []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if sha256Re.MatchString(line) { + hashes = append(hashes, line) + } } - data = *dataContent + return hashes +} - filename := sha256 + ".zip" - destPath := filepath.Join(outputFlag, filename) - _, err = util.WriteBytesFile(destPath, &data) - return err +func downloadFiles(web webapi.Service, token string, hashes []string) { + model := newDownloadModel(hashes, web, token, outputFlag, parallelFlag) + p := tea.NewProgram(model) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) + os.Exit(1) + } } diff --git a/cmd/downloadui.go b/cmd/downloadui.go new file mode 100644 index 0000000..9974c74 --- /dev/null +++ b/cmd/downloadui.go @@ -0,0 +1,206 @@ +// 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" + "path/filepath" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/saferwall/cli/internal/util" + "github.com/saferwall/cli/internal/webapi" +) + +// Per-file state in the download TUI. +type dlState int + +const ( + dlPending dlState = iota + dlDownloading // download in progress + dlDone // download finished successfully + dlError // an error occurred +) + +// One row in the download UI. +type dlRow struct { + sha256 string + state dlState + spinner spinner.Model + dest string // destination file path (set on success) + err error +} + +// Top-level bubbletea model for downloads. +type downloadModel struct { + files []dlRow + web webapi.Service + token string + outDir string + parallel int + done bool +} + +// --- Messages --- + +type fileDownloadedMsg struct { + index int + dest string + err error +} + +// --- Commands (async I/O) --- + +func downloadFileCmd(index int, web webapi.Service, sha256, token, outDir string) tea.Cmd { + return func() tea.Msg { + dataContent, err := web.Download(sha256, token) + if err != nil { + return fileDownloadedMsg{index: index, err: fmt.Errorf("download: %w", err)} + } + + filename := sha256 + ".zip" + destPath := filepath.Join(outDir, filename) + if _, err := util.WriteBytesFile(destPath, dataContent); err != nil { + return fileDownloadedMsg{index: index, err: fmt.Errorf("write file: %w", err)} + } + + return fileDownloadedMsg{index: index, dest: destPath} + } +} + +// --- Model interface --- + +func newDownloadModel(hashes []string, web webapi.Service, token, outDir string, parallel int) downloadModel { + if parallel < 1 { + parallel = 1 + } + rows := make([]dlRow, len(hashes)) + for i, h := range hashes { + s := spinner.New() + s.Spinner = spinner.Dot + rows[i] = dlRow{ + sha256: h, + state: dlPending, + spinner: s, + } + } + return downloadModel{ + files: rows, + web: web, + token: token, + outDir: outDir, + parallel: parallel, + } +} + +func (m downloadModel) Init() tea.Cmd { + if len(m.files) == 0 { + return tea.Quit + } + + n := min(m.parallel, len(m.files)) + var cmds []tea.Cmd + for i := range n { + m.files[i].state = dlDownloading + cmds = append(cmds, + downloadFileCmd(i, m.web, m.files[i].sha256, m.token, m.outDir), + m.files[i].spinner.Tick, + ) + } + return tea.Batch(cmds...) +} + +func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + case spinner.TickMsg: + for i := range m.files { + if m.files[i].state == dlDownloading { + var cmd tea.Cmd + m.files[i].spinner, cmd = m.files[i].spinner.Update(msg) + cmds = append(cmds, cmd) + } + } + + case fileDownloadedMsg: + i := msg.index + if msg.err != nil { + m.files[i].state = dlError + m.files[i].err = msg.err + } else { + m.files[i].state = dlDone + m.files[i].dest = msg.dest + } + return m, m.maybeQuitOrNext() + } + + return m, tea.Batch(cmds...) +} + +// maybeQuitOrNext launches pending downloads up to the parallel limit, or quits if all done. +func (m *downloadModel) maybeQuitOrNext() tea.Cmd { + inFlight := 0 + allDone := true + for _, f := range m.files { + switch f.state { + case dlDownloading: + inFlight++ + allDone = false + case dlPending: + allDone = false + } + } + if allDone { + m.done = true + return tea.Quit + } + + var cmds []tea.Cmd + for i := range m.files { + if inFlight >= m.parallel { + break + } + if m.files[i].state == dlPending { + m.files[i].state = dlDownloading + cmds = append(cmds, + downloadFileCmd(i, m.web, m.files[i].sha256, m.token, m.outDir), + m.files[i].spinner.Tick, + ) + inFlight++ + } + } + if len(cmds) > 0 { + return tea.Batch(cmds...) + } + return nil +} + +func (m downloadModel) View() string { + var s string + for _, f := range m.files { + sha := truncSha(f.sha256) + switch f.state { + case dlPending: + s += styleDim.Render(" "+sha) + "\n" + + case dlDownloading: + s += f.spinner.View() + styleLabel.Render(" Downloading ") + sha + " ...\n" + + case dlDone: + s += styleSuccess.Render("✓") + " " + sha + " " + styleDim.Render(f.dest) + "\n" + + case dlError: + s += styleError.Render("✗") + " " + sha + " " + styleError.Render(f.err.Error()) + "\n" + } + } + return s +} From 6532e0a46db75a24178382176cc58e134c126273 Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Tue, 3 Mar 2026 15:42:48 +0000 Subject: [PATCH 4/4] support extract flag --- README.md | 3 +++ cmd/download.go | 5 +++- cmd/downloadui.go | 67 +++++++++++++++++++++++++++++++++++++++-------- go.mod | 1 + go.sum | 2 ++ 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2c35599..dd67d8d 100755 --- a/README.md +++ b/README.md @@ -65,4 +65,7 @@ saferwall-cli download # Batch from a text file saferwall-cli download hashes.txt + +# Extract from zip (password: infected) instead of keeping the .zip +saferwall-cli download -x ``` diff --git a/cmd/download.go b/cmd/download.go index 757133c..f0af104 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -18,6 +18,7 @@ import ( ) var outputFlag string +var extractFlag bool func init() { ex, err := os.Executable() @@ -29,6 +30,8 @@ func init() { "Destination directory where to save samples. (default=current dir)") downloadCmd.Flags().IntVarP(¶llelFlag, "parallel", "p", 4, "Number of files to download in parallel") + downloadCmd.Flags().BoolVarP(&extractFlag, "extract", "x", false, + "Extract samples from zip (password: infected)") } var downloadCmd = &cobra.Command{ @@ -79,7 +82,7 @@ func collectHashes(arg string) []string { } func downloadFiles(web webapi.Service, token string, hashes []string) { - model := newDownloadModel(hashes, web, token, outputFlag, parallelFlag) + model := newDownloadModel(hashes, web, token, outputFlag, parallelFlag, extractFlag) p := tea.NewProgram(model) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) diff --git a/cmd/downloadui.go b/cmd/downloadui.go index 9974c74..9e06915 100644 --- a/cmd/downloadui.go +++ b/cmd/downloadui.go @@ -6,24 +6,29 @@ package cmd import ( "fmt" + "os" "path/filepath" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/saferwall/cli/internal/util" "github.com/saferwall/cli/internal/webapi" + yzip "github.com/yeka/zip" ) // Per-file state in the download TUI. type dlState int const ( - dlPending dlState = iota - dlDownloading // download in progress - dlDone // download finished successfully - dlError // an error occurred + dlPending dlState = iota + dlDownloading // download in progress + dlDone // download finished successfully + dlError // an error occurred ) +// Zip password used to extract samples. +const zipPassword = "infected" + // One row in the download UI. type dlRow struct { sha256 string @@ -40,6 +45,7 @@ type downloadModel struct { token string outDir string parallel int + extract bool done bool } @@ -53,26 +59,64 @@ type fileDownloadedMsg struct { // --- Commands (async I/O) --- -func downloadFileCmd(index int, web webapi.Service, sha256, token, outDir string) tea.Cmd { +func downloadFileCmd(index int, web webapi.Service, sha256, token, outDir string, extract bool) tea.Cmd { return func() tea.Msg { dataContent, err := web.Download(sha256, token) if err != nil { return fileDownloadedMsg{index: index, err: fmt.Errorf("download: %w", err)} } - filename := sha256 + ".zip" - destPath := filepath.Join(outDir, filename) - if _, err := util.WriteBytesFile(destPath, dataContent); err != nil { + zipPath := filepath.Join(outDir, sha256+".zip") + if _, err := util.WriteBytesFile(zipPath, dataContent); err != nil { return fileDownloadedMsg{index: index, err: fmt.Errorf("write file: %w", err)} } + if !extract { + return fileDownloadedMsg{index: index, dest: zipPath} + } + + destPath, err := extractZip(zipPath, outDir) + if err != nil { + return fileDownloadedMsg{index: index, err: fmt.Errorf("extract: %w", err)} + } + + os.Remove(zipPath) return fileDownloadedMsg{index: index, dest: destPath} } } +// extractZip opens a password-protected zip and extracts the first file. +func extractZip(zipPath, outDir string) (string, error) { + r, err := yzip.OpenReader(zipPath) + if err != nil { + return "", err + } + defer r.Close() + + if len(r.File) == 0 { + return "", fmt.Errorf("zip archive is empty") + } + + f := r.File[0] + f.SetPassword(zipPassword) + + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + + destPath := filepath.Join(outDir, f.Name) + if _, err := util.WriteBytesFile(destPath, rc); err != nil { + return "", err + } + + return destPath, nil +} + // --- Model interface --- -func newDownloadModel(hashes []string, web webapi.Service, token, outDir string, parallel int) downloadModel { +func newDownloadModel(hashes []string, web webapi.Service, token, outDir string, parallel int, extract bool) downloadModel { if parallel < 1 { parallel = 1 } @@ -92,6 +136,7 @@ func newDownloadModel(hashes []string, web webapi.Service, token, outDir string, token: token, outDir: outDir, parallel: parallel, + extract: extract, } } @@ -105,7 +150,7 @@ func (m downloadModel) Init() tea.Cmd { for i := range n { m.files[i].state = dlDownloading cmds = append(cmds, - downloadFileCmd(i, m.web, m.files[i].sha256, m.token, m.outDir), + downloadFileCmd(i, m.web, m.files[i].sha256, m.token, m.outDir, m.extract), m.files[i].spinner.Tick, ) } @@ -172,7 +217,7 @@ func (m *downloadModel) maybeQuitOrNext() tea.Cmd { if m.files[i].state == dlPending { m.files[i].state = dlDownloading cmds = append(cmds, - downloadFileCmd(i, m.web, m.files[i].sha256, m.token, m.outDir), + downloadFileCmd(i, m.web, m.files[i].sha256, m.token, m.outDir, m.extract), m.files[i].spinner.Tick, ) inFlight++ diff --git a/go.mod b/go.mod index 0a8cbb5..f14f303 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 2dee150..a0e8da9 100755 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=