diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27584c3..814754a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,9 @@ jobs: run: | go env -w GOFLAGS=-mod=mod go build -v ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -v ./... diff --git a/cmd/scan_test.go b/cmd/scan_test.go new file mode 100644 index 0000000..6209ded --- /dev/null +++ b/cmd/scan_test.go @@ -0,0 +1,117 @@ +// 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 ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/saferwall/cli/internal/entity" +) + +const testHash = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f" + +func TestBuildScanSummary(t *testing.T) { + success := true + file := entity.File{ + SHA256: testHash, + Size: 2048, + Classification: "Ransomware.Wannacry", + Format: "pe", + Extension: "exe", + Encrypted: true, + DecryptionSuccess: &success, + SuccessfulPassword: "infected", + AttemptedPasswords: []string{"infected", "malware"}, + MultiAV: map[string]any{ + "last_scan": map[string]any{ + "stats": map[string]any{ + "positives": float64(12), + "engines_count": float64(14), + }, + }, + }, + } + + got := buildScanSummary(file) + + if got.SHA256 != testHash { + t.Errorf("sha256 = %q, want %q", got.SHA256, testHash) + } + if got.Size != 2048 { + t.Errorf("size = %d, want 2048", got.Size) + } + if got.Classification != "Ransomware.Wannacry" { + t.Errorf("classification = %q, want Ransomware.Wannacry", got.Classification) + } + if got.FileFormat != "pe" || got.FileExtension != "exe" { + t.Errorf("format/extension = %q/%q, want pe/exe", got.FileFormat, got.FileExtension) + } + if !got.Encrypted || got.DecryptionSuccess == nil || !*got.DecryptionSuccess { + t.Error("encryption fields not carried over") + } + if got.SuccessfulPassword != "infected" { + t.Errorf("successful_password = %q, want infected", got.SuccessfulPassword) + } + if got.MultiAV == nil { + t.Fatal("multiav summary = nil, want populated") + } + if got.MultiAV.Positives != 12 || got.MultiAV.EnginesCount != 14 { + t.Errorf("multiav = %d/%d, want 12/14", got.MultiAV.Positives, got.MultiAV.EnginesCount) + } +} + +func TestBuildScanSummaryNoMultiAV(t *testing.T) { + got := buildScanSummary(entity.File{SHA256: testHash}) + if got.MultiAV != nil { + t.Errorf("multiav = %+v, want nil when last_scan stats are absent", got.MultiAV) + } +} + +func TestSha256Re(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {testHash, true}, + {"275A021BBFB6489E54D471899F7DB9D1663FC695EC2FE2A2C4538AABF651FD0F", true}, + {"not-a-hash", false}, + {testHash[:63], false}, + {testHash + "0", false}, + {"", false}, + } + for _, tt := range tests { + if got := sha256Re.MatchString(tt.input); got != tt.want { + t.Errorf("sha256Re.MatchString(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestCollectHashesSingleHash(t *testing.T) { + got := collectHashes(testHash) + if !reflect.DeepEqual(got, []string{testHash}) { + t.Errorf("collectHashes() = %v, want [%s]", got, testHash) + } +} + +func TestCollectHashesFromFile(t *testing.T) { + other := "0000000000000000000000000000000000000000000000000000000000000000" + path := filepath.Join(t.TempDir(), "hashes.txt") + content := testHash + "\n" + + " " + other + " \n" + // surrounding whitespace is trimmed + "garbage line\n" + + "\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got := collectHashes(path) + want := []string{testHash, other} + if !reflect.DeepEqual(got, want) { + t.Errorf("collectHashes() = %v, want %v", got, want) + } +} diff --git a/internal/util/utils_test.go b/internal/util/utils_test.go new file mode 100644 index 0000000..b6d44d2 --- /dev/null +++ b/internal/util/utils_test.go @@ -0,0 +1,162 @@ +// 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 ( + "bytes" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestGetSha256(t *testing.T) { + tests := []struct { + name string + input []byte + want string + }{ + { + name: "empty input", + input: []byte{}, + want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "known vector", + input: []byte("abc"), + want: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetSha256(tt.input); got != tt.want { + t.Errorf("GetSha256() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestReadAll(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.bin") + content := bytes.Repeat([]byte("saferwall"), 1000) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + got, err := ReadAll(path) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("ReadAll() returned %d bytes, want %d", len(got), len(content)) + } +} + +func TestReadAllMissingFile(t *testing.T) { + if _, err := ReadAll(filepath.Join(t.TempDir(), "nope")); err == nil { + t.Error("ReadAll() expected error for missing file, got nil") + } +} + +func TestWriteBytesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "out.bin") + content := []byte("hello saferwall") + + n, err := WriteBytesFile(path, bytes.NewReader(content)) + if err != nil { + t.Fatalf("WriteBytesFile() error = %v", err) + } + if n != len(content) { + t.Errorf("WriteBytesFile() wrote %d bytes, want %d", n, len(content)) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, content) { + t.Errorf("file content = %q, want %q", got, content) + } +} + +func TestExists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + if err := os.WriteFile(path, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + if !Exists(path) { + t.Errorf("Exists(%q) = false, want true", path) + } + if !Exists(dir) { + t.Errorf("Exists(%q) = false, want true for directory", dir) + } + if Exists(filepath.Join(dir, "missing")) { + t.Error("Exists() = true for missing file, want false") + } +} + +func TestMkDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "newdir") + + if !MkDir(dir) { + t.Fatalf("MkDir(%q) = false, want true", dir) + } + if !Exists(dir) { + t.Errorf("MkDir(%q) did not create the directory", dir) + } + // Calling it again on an existing directory succeeds. + if !MkDir(dir) { + t.Errorf("MkDir(%q) = false on existing directory, want true", dir) + } +} + +func TestStringInSlice(t *testing.T) { + list := []string{"a", "b", "c"} + if !StringInSlice("b", list) { + t.Error(`StringInSlice("b") = false, want true`) + } + if StringInSlice("z", list) { + t.Error(`StringInSlice("z") = true, want false`) + } + if StringInSlice("a", nil) { + t.Error("StringInSlice() on nil slice = true, want false") + } +} + +func TestUniqueSlice(t *testing.T) { + got := UniqueSlice([]string{"a", "b", "a", "c", "b"}) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Errorf("UniqueSlice() = %v, want %v", got, want) + } +} + +func TestWalkAllFilesInDir(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "sub") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatal(err) + } + for _, f := range []string{ + filepath.Join(dir, "a.txt"), + filepath.Join(sub, "b.txt"), + } { + if err := os.WriteFile(f, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + got, err := WalkAllFilesInDir(dir) + if err != nil { + t.Fatalf("WalkAllFilesInDir() error = %v", err) + } + want := []string{filepath.Join(dir, "a.txt"), filepath.Join(sub, "b.txt")} + if !reflect.DeepEqual(got, want) { + t.Errorf("WalkAllFilesInDir() = %v, want %v", got, want) + } +} diff --git a/internal/webapi/files_test.go b/internal/webapi/files_test.go new file mode 100644 index 0000000..0217935 --- /dev/null +++ b/internal/webapi/files_test.go @@ -0,0 +1,399 @@ +// 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 webapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/saferwall/cli/internal/entity" +) + +const ( + testSHA256 = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f" + testAPIKey = "test-api-key" +) + +func TestScan(t *testing.T) { + sample := filepath.Join(t.TempDir(), "sample.bin") + content := []byte("fake malware sample") + if err := os.WriteFile(sample, content, 0644); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/v1/files/" { + t.Errorf("path = %s, want /v1/files/", r.URL.Path) + } + if got := r.Header.Get("X-Api-Key"); got != testAPIKey { + t.Errorf("X-Api-Key = %q, want %q", got, testAPIKey) + } + + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("failed to parse multipart form: %v", err) + } + for key, want := range map[string]string{ + "skip_detonation": "false", + "os": "windows-10-x64", + "timeout": "30", + } { + if got := r.FormValue(key); got != want { + t.Errorf("form field %s = %q, want %q", key, got, want) + } + } + + file, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("missing file part: %v", err) + } + defer file.Close() + if header.Filename != "sample.bin" { + t.Errorf("filename = %q, want sample.bin", header.Filename) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"sha256": testSHA256}) + })) + defer srv.Close() + + svc := New(srv.URL) + file, err := svc.Scan(sample, testAPIKey, "windows-10-x64", true, 30) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + if file.SHA256 != testSHA256 { + t.Errorf("Scan() sha256 = %q, want %q", file.SHA256, testSHA256) + } +} + +func TestScanUploadFailure(t *testing.T) { + sample := filepath.Join(t.TempDir(), "sample.bin") + if err := os.WriteFile(sample, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message": "quota exceeded"}`, http.StatusTooManyRequests) + })) + defer srv.Close() + + svc := New(srv.URL) + if _, err := svc.Scan(sample, testAPIKey, "windows-10-x64", false, 15); err == nil { + t.Error("Scan() expected error on HTTP 429, got nil") + } +} + +func TestScanMissingFile(t *testing.T) { + svc := New("http://unused.invalid") + if _, err := svc.Scan(filepath.Join(t.TempDir(), "nope"), testAPIKey, "windows-10-x64", false, 15); err == nil { + t.Error("Scan() expected error for missing local file, got nil") + } +} + +func TestRescan(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if want := "/v1/files/" + testSHA256 + "/rescan"; r.URL.Path != want { + t.Errorf("path = %s, want %s", r.URL.Path, want) + } + if got := r.Header.Get("X-Api-Key"); got != testAPIKey { + t.Errorf("X-Api-Key = %q, want %q", got, testAPIKey) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + svc := New(srv.URL) + if err := svc.Rescan(testSHA256, testAPIKey, "windows-7-x64", false, 60); err != nil { + t.Fatalf("Rescan() error = %v", err) + } + + if got := gotBody["skip_detonation"]; got != true { + t.Errorf("skip_detonation = %v, want true", got) + } + if got := gotBody["os"]; got != "windows-7-x64" { + t.Errorf("os = %v, want windows-7-x64", got) + } + if got := gotBody["timeout"]; got != float64(60) { + t.Errorf("timeout = %v, want 60", got) + } +} + +func TestFileExists(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Errorf("method = %s, want HEAD", r.Method) + } + if r.URL.Path == "/v1/files/"+testSHA256 { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + svc := New(srv.URL) + + exists, err := svc.FileExists(testSHA256) + if err != nil { + t.Fatalf("FileExists() error = %v", err) + } + if !exists { + t.Error("FileExists() = false for known hash, want true") + } + + exists, err = svc.FileExists("0000000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Fatalf("FileExists() error = %v", err) + } + if exists { + t.Error("FileExists() = true for unknown hash, want false") + } +} + +func TestGetFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if want := "/v1/files/" + testSHA256; r.URL.Path != want { + t.Errorf("path = %s, want %s", r.URL.Path, want) + } + json.NewEncoder(w).Encode(map[string]any{ + "sha256": testSHA256, + "size": 1024, + "classification": "Trojan.Generic", + "file_format": "pe", + }) + })) + defer srv.Close() + + svc := New(srv.URL) + var file entity.File + if err := svc.GetFile(testSHA256, &file); err != nil { + t.Fatalf("GetFile() error = %v", err) + } + if file.SHA256 != testSHA256 { + t.Errorf("sha256 = %q, want %q", file.SHA256, testSHA256) + } + if file.Size != 1024 { + t.Errorf("size = %d, want 1024", file.Size) + } + if file.Classification != "Trojan.Generic" { + t.Errorf("classification = %q, want Trojan.Generic", file.Classification) + } +} + +func TestGetFileStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("fields"); got != "status" { + t.Errorf("fields query = %q, want status", got) + } + json.NewEncoder(w).Encode(map[string]int{"status": 3}) + })) + defer srv.Close() + + svc := New(srv.URL) + status, err := svc.GetFileStatus(testSHA256) + if err != nil { + t.Fatalf("GetFileStatus() error = %v", err) + } + if status != 3 { + t.Errorf("GetFileStatus() = %d, want 3", status) + } +} + +func TestGetFileStatusError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + svc := New(srv.URL) + if _, err := svc.GetFileStatus(testSHA256); err == nil { + t.Error("GetFileStatus() expected error on HTTP 404, got nil") + } +} + +func TestListFiles(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != testAPIKey { + t.Errorf("X-Api-Key = %q, want %q", got, testAPIKey) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("page query = %q, want 2", got) + } + json.NewEncoder(w).Encode(map[string]any{ + "page": 2, + "per_page": 1000, + "page_count": 5, + "total_count": 4321, + "items": []map[string]string{{"sha256": testSHA256}}, + }) + })) + defer srv.Close() + + svc := New(srv.URL) + pages, err := svc.ListFiles(testAPIKey, 2) + if err != nil { + t.Fatalf("ListFiles() error = %v", err) + } + if pages.Page != 2 || pages.PageCount != 5 || pages.TotalCount != 4321 { + t.Errorf("ListFiles() = %+v, want page=2 page_count=5 total_count=4321", pages) + } +} + +func TestListFilesError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"message": "invalid api key"}) + })) + defer srv.Close() + + svc := New(srv.URL) + _, err := svc.ListFiles(testAPIKey, 1) + if err == nil { + t.Fatal("ListFiles() expected error on HTTP 401, got nil") + } + if err.Error() != "invalid api key" { + t.Errorf("ListFiles() error = %q, want %q", err.Error(), "invalid api key") + } +} + +func TestSearchFiles(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/v1/files/search/" { + t.Errorf("path = %s, want /v1/files/search/", r.URL.Path) + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + if got := body["query"]; got != "class:trojan" { + t.Errorf("query = %v, want class:trojan", got) + } + if got := body["page"]; got != float64(1) { + t.Errorf("page = %v, want 1", got) + } + if got := body["per_page"]; got != float64(20) { + t.Errorf("per_page = %v, want 20", got) + } + + json.NewEncoder(w).Encode(map[string]any{ + "page": 1, + "per_page": 20, + "page_count": 1, + "total_count": 1, + "items": []map[string]any{{ + "id": testSHA256, + "class": "trojan", + }}, + }) + })) + defer srv.Close() + + svc := New(srv.URL) + result, err := svc.SearchFiles("class:trojan", testAPIKey, 1, 20) + if err != nil { + t.Fatalf("SearchFiles() error = %v", err) + } + if len(result.Items) != 1 { + t.Fatalf("SearchFiles() returned %d items, want 1", len(result.Items)) + } + if result.Items[0].ID != testSHA256 { + t.Errorf("item id = %q, want %q", result.Items[0].ID, testSHA256) + } + if result.Items[0].Classification != "trojan" { + t.Errorf("item class = %q, want trojan", result.Items[0].Classification) + } +} + +func TestSearchFilesErrorWithMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"message": "invalid query"}) + })) + defer srv.Close() + + svc := New(srv.URL) + _, err := svc.SearchFiles("bogus", testAPIKey, 1, 20) + if err == nil { + t.Fatal("SearchFiles() expected error on HTTP 400, got nil") + } + if err.Error() != "invalid query" { + t.Errorf("SearchFiles() error = %q, want %q", err.Error(), "invalid query") + } +} + +func TestSearchFilesErrorWithoutMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + svc := New(srv.URL) + if _, err := svc.SearchFiles("q", testAPIKey, 1, 20); err == nil { + t.Error("SearchFiles() expected error on HTTP 500, got nil") + } +} + +func TestDownload(t *testing.T) { + content := []byte("sample bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if want := "/v1/files/" + testSHA256 + "/download"; r.URL.Path != want { + t.Errorf("path = %s, want %s", r.URL.Path, want) + } + if got := r.Header.Get("X-Api-Key"); got != testAPIKey { + t.Errorf("X-Api-Key = %q, want %q", got, testAPIKey) + } + w.Write(content) + })) + defer srv.Close() + + svc := New(srv.URL) + buf, err := svc.Download(testSHA256, testAPIKey) + if err != nil { + t.Fatalf("Download() error = %v", err) + } + if buf.String() != string(content) { + t.Errorf("Download() = %q, want %q", buf.String(), content) + } +} + +func TestDelete(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + if r.Method != http.MethodDelete { + t.Errorf("method = %s, want DELETE", r.Method) + } + if want := "/v1/files/" + testSHA256; r.URL.Path != want { + t.Errorf("path = %s, want %s", r.URL.Path, want) + } + })) + defer srv.Close() + + svc := New(srv.URL) + if err := svc.Delete(testSHA256, testAPIKey); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if !called { + t.Error("Delete() never reached the server") + } +}