diff --git a/README.md b/README.md index 6663752..b317276 100755 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ saferwall-cli scan -d -t 30 -o windows-10-x64 /path/to/sample | `--force` | `-f` | `false` | Force rescan if the file already exists | | `--parallel` | `-p` | `1` | Number of files to scan in parallel | | `--enableDetonation` | `-d` | `false` | Enable detonation (dynamic analysis) | -| `--timeout` | `-t` | `15` | Detonation duration in seconds | +| `--timeout` | `-t` | `30` | Detonation duration in seconds | | `--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 01742f9..8eaa952 100644 --- a/cmd/rescan.go +++ b/cmd/rescan.go @@ -22,7 +22,7 @@ func init() { "Number of files to rescan in parallel") reScanCmd.Flags().BoolVarP(&enableDetonationFlag, "enableDetonation", "d", false, "Enable sandbox detonation (skipped by default)") - reScanCmd.Flags().IntVarP(&timeoutFlag, "timeout", "t", 15, + reScanCmd.Flags().IntVarP(&timeoutFlag, "timeout", "t", defaultDetonationTimeout, "Detonation duration in seconds") reScanCmd.Flags().StringVarP(&osFlag, "os", "o", "windows-10-x64", "Preferred OS for detonation, choice(windows-7-x64 | windows-10-x64 | windows-11-x64)") diff --git a/cmd/scan.go b/cmd/scan.go index a0c29b4..9676348 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -17,7 +17,8 @@ import ( ) const ( - statusCompleted = 3 + statusCompleted = 3 + defaultDetonationTimeout = 30 pollInterval = 5 * time.Second ) @@ -36,7 +37,7 @@ func init() { "Number of files to scan in parallel") scanCmd.Flags().BoolVarP(&enableDetonationFlag, "enableDetonation", "d", false, "Enable sandbox detonation (skipped by default)") - scanCmd.Flags().IntVarP(&timeoutFlag, "timeout", "t", 15, + scanCmd.Flags().IntVarP(&timeoutFlag, "timeout", "t", defaultDetonationTimeout, "Detonation duration in seconds") scanCmd.Flags().StringVarP(&osFlag, "os", "o", "windows-10-x64", "Preferred OS for detonation, choice(windows-7-x64 | windows-10-x64 | windows-11-x64)") diff --git a/cmd/view.go b/cmd/view.go index ca0921d..c6318cf 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -8,6 +8,7 @@ import ( "fmt" "sort" "strings" + "sync" "time" "github.com/charmbracelet/lipgloss" @@ -16,6 +17,10 @@ import ( "github.com/spf13/cobra" ) +// viewBehaviorID selects which behavior report (sandbox run) to display in +// the Dynamic Analysis section. +var viewBehaviorID string + var viewCmd = &cobra.Command{ Use: "view ", Short: "View scan results for a file by its SHA256 hash", @@ -31,11 +36,13 @@ var viewCmd = &cobra.Command{ } printFileReport(file, webSvc) - return nil + return printDynamicAnalysis(file, webSvc, viewBehaviorID) }, } func init() { + viewCmd.Flags().StringVarP(&viewBehaviorID, "behavior-id", "b", "", + "behavior report ID (sandbox run) to display in the Dynamic Analysis section") rootCmd.AddCommand(viewCmd) } @@ -46,6 +53,7 @@ var ( keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) detectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) cleanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) avNameStyle = lipgloss.NewStyle().Width(24) ) @@ -338,3 +346,491 @@ func submissionFilename(submissions []entity.Submission) string { } return "" } + +// selectBehaviorID picks the behavior run to show in detail: the requested ID +// wins regardless of status (so failed runs can be inspected), then the +// default report if completed, then the newest completed run. Returns an +// empty string when no run is selectable, and an error only when an +// explicitly requested ID does not exist on the file. +func selectBehaviorID(reports map[string]entity.BehaviorReportSummary, defaultID, requested string) (string, error) { + if requested != "" { + if _, ok := reports[requested]; ok { + return requested, nil + } + return "", fmt.Errorf("behavior report %s not found for this file", requested) + } + + if def, ok := reports[defaultID]; ok && def.Status == entity.BehaviorStatusCompleted { + return defaultID, nil + } + + bestID := "" + var bestTS int64 = -1 + for id, r := range reports { + if r.Status != entity.BehaviorStatusCompleted { + continue + } + ts := behaviorRunTime(r) + if ts > bestTS || (ts == bestTS && id > bestID) { + bestTS, bestID = ts, id + } + } + return bestID, nil +} + +// behaviorRunTime returns the most relevant timestamp of a run for ordering. +func behaviorRunTime(r entity.BehaviorReportSummary) int64 { + if r.FinishedAt != 0 { + return r.FinishedAt + } + if r.StartedAt != 0 { + return r.StartedAt + } + return r.QueuedAt +} + +// behaviorDetail aggregates the results of the parallel behavior fetches. +type behaviorDetail struct { + env entity.BehaviorEnvironment + envErr error + capabilities []entity.Capability + processTree []entity.Process + counts map[string]int // event type -> total; absent key means the count failed +} + +// fetchBehaviorDetail fans out the behavior document and sys-event count +// requests concurrently. `env` is always present on a behavior document; +// `capabilities` and `proc_tree` are omitted when empty and must be fetched +// individually since a projection on a missing field fails the whole lookup — +// an error on those simply means there is nothing to display. +func fetchBehaviorDetail(webSvc webapi.Service, id string) behaviorDetail { + d := behaviorDetail{counts: make(map[string]int)} + + eventTypes := []string{"network", "file", "registry"} + countVals := make([]int, len(eventTypes)) + countErrs := make([]error, len(eventTypes)) + + var wg sync.WaitGroup + wg.Add(3 + len(eventTypes)) + go func() { + defer wg.Done() + var doc entity.Behavior + if d.envErr = webSvc.GetBehaviorReport(id, []string{"env"}, &doc); d.envErr == nil { + d.env = doc.Environment + } + }() + go func() { + defer wg.Done() + var doc entity.Behavior + if err := webSvc.GetBehaviorReport(id, []string{"capabilities"}, &doc); err == nil { + d.capabilities = doc.Capabilities + } + }() + go func() { + defer wg.Done() + var doc entity.Behavior + if err := webSvc.GetBehaviorReport(id, []string{"proc_tree"}, &doc); err == nil { + d.processTree = doc.ProcessTree + } + }() + for i, t := range eventTypes { + go func(i int, t string) { + defer wg.Done() + countVals[i], countErrs[i] = webSvc.CountSysEvents(id, t) + }(i, t) + } + wg.Wait() + + for i, t := range eventTypes { + if countErrs[i] == nil { + d.counts[t] = countVals[i] + } + } + return d +} + +// printDynamicAnalysis renders the Dynamic Analysis section of the report. +func printDynamicAnalysis(file entity.File, webSvc webapi.Service, requestedID string) error { + selectedID, err := selectBehaviorID(file.BehaviorReports, file.DefaultBehaviorID, requestedID) + if err != nil { + return err + } + + fmt.Println(headerStyle.Render("Dynamic Analysis")) + if len(file.BehaviorReports) == 0 { + fmt.Println(" " + styleDim.Render("No dynamic analysis available.")) + fmt.Println() + return nil + } + + printBehaviorRunsTable(file.BehaviorReports, selectedID) + + if selectedID != "" { + sum := file.BehaviorReports[selectedID] + detail := fetchBehaviorDetail(webSvc, selectedID) + printBehaviorRunDetail(selectedID, sum, detail) + } + return nil +} + +// renderBehaviorStatus colors a behavior run status. +func renderBehaviorStatus(status string) string { + switch status { + case entity.BehaviorStatusCompleted: + return cleanStyle.Render(status) + case entity.BehaviorStatusPartial, entity.BehaviorStatusFailed: + return detectStyle.Render(status) + default: + return styleDim.Render(status) + } +} + +// renderPaddedBehaviorStatus is renderBehaviorStatus with the raw text +// left-padded to a fixed width before styling, for column alignment. +func renderPaddedBehaviorStatus(status string) string { + padded := fmt.Sprintf("%-10s", status) + switch status { + case entity.BehaviorStatusCompleted: + return cleanStyle.Render(padded) + case entity.BehaviorStatusPartial, entity.BehaviorStatusFailed: + return detectStyle.Render(padded) + default: + return styleDim.Render(padded) + } +} + +// printBehaviorRunDetail renders the selected run: environment, evidence, +// capabilities, process tree and activity counts. +func printBehaviorRunDetail(id string, sum entity.BehaviorReportSummary, d behaviorDetail) { + printKV("Report ID", id+" "+renderBehaviorStatus("("+sum.Status+")")) + if sum.ScanConfig.Country != "" { + printKV("Country", sum.ScanConfig.Country) + } + if d.envErr == nil && d.env.SandboxVersion != "" { + sandbox := "v" + d.env.SandboxVersion + if d.env.GuestHealthy { + sandbox += styleDim.Render(" (guest healthy)") + } + printKV("Sandbox", sandbox) + } + if sum.AttemptCount > 1 { + printKV("Attempts", fmt.Sprintf("%d", sum.AttemptCount)) + } + + if sum.Failure != nil { + failure := sum.Failure.Class + if sum.Failure.Stage != "" { + failure += " at stage " + sum.Failure.Stage + } + if sum.Failure.Message != "" { + failure += ": " + sum.Failure.Message + } + printKV("Failure", detectStyle.Render(failure)) + } + + ev := sum.Evidence + printKV("Evidence", fmt.Sprintf( + "malware YARA: %d · high behavior: %d · high other: %d · medium: %d · rules: %d · detected artifacts: %d", + ev.MalwareYARA, ev.HighBehavior, ev.HighOther, ev.Medium, ev.TotalRules, ev.DetectedArtifacts)) + printKV("Activity", renderActivityCounts(sum, d.counts)) + fmt.Println() + + if d.envErr != nil { + fmt.Println(" " + styleError.Render("warning: could not fetch behavior report details: "+d.envErr.Error())) + fmt.Println() + } + printCapabilities(d.capabilities) + printProcessTree(d.processTree) +} + +// renderActivityCounts builds the one-line activity summary. Counts whose +// fetch failed render as n/a. +func renderActivityCounts(sum entity.BehaviorReportSummary, counts map[string]int) string { + count := func(eventType string) string { + if n, ok := counts[eventType]; ok { + return fmt.Sprintf("%d", n) + } + return "n/a" + } + return fmt.Sprintf("network events: %s · file events: %s · registry events: %s · artifacts: %d · screenshots: %d", + count("network"), count("file"), count("registry"), sum.ArtifactCount, sum.ScreenshotsCount) +} + +// severityRank orders capability groups from most to least severe. +func severityRank(severity string) int { + switch severity { + case "high": + return 0 + case "suspicious": + return 1 + case "informative": + return 2 + default: + return 3 + } +} + +// renderSeverity colors a capability severity label, left-padded to a fixed +// width before styling so ANSI codes don't break column alignment. +func renderSeverity(severity string) string { + padded := fmt.Sprintf("%-12s", severity) + switch severity { + case "high": + return detectStyle.Render(padded) + case "suspicious": + return warnStyle.Render(padded) + default: + return styleDim.Render(padded) + } +} + +// printCapabilities renders detected capabilities grouped by severity, +// deduplicated by (severity, description). +func printCapabilities(caps []entity.Capability) { + if len(caps) == 0 { + return + } + + seen := make(map[string]bool) + var unique []entity.Capability + for _, c := range caps { + key := c.Severity + "\x00" + c.Description + if seen[key] { + continue + } + seen[key] = true + unique = append(unique, c) + } + + sort.SliceStable(unique, func(i, j int) bool { + ri, rj := severityRank(unique[i].Severity), severityRank(unique[j].Severity) + if ri != rj { + return ri < rj + } + return unique[i].Description < unique[j].Description + }) + + fmt.Println(headerStyle.Render(fmt.Sprintf("Capabilities (%d)", len(unique)))) + for _, c := range unique { + bullet := "●" + switch severityRank(c.Severity) { + case 0: + bullet = detectStyle.Render(bullet) + case 1: + bullet = warnStyle.Render(bullet) + default: + bullet = styleDim.Render(bullet) + } + origin := c.Category + if c.Module != "" { + origin += "/" + c.Module + } + fmt.Printf(" %s %s %s %s\n", + bullet, renderSeverity(c.Severity), c.Description, styleDim.Render("("+origin+")")) + } + fmt.Println() +} + +// procNode is one node of the nested process tree. +type procNode struct { + proc entity.Process + children []*procNode +} + +// buildProcTree nests a flat process list by parent PID. A process whose +// parent is absent from the set (or empty) becomes a root. Children are +// sorted by PID; cycles and self-parenting cannot loop because each process +// is attached exactly once. +func buildProcTree(procs []entity.Process) []*procNode { + nodes := make(map[string]*procNode, len(procs)) + order := make([]*procNode, 0, len(procs)) + for _, p := range procs { + if _, ok := nodes[p.PID]; ok { + continue + } + n := &procNode{proc: p} + nodes[p.PID] = n + order = append(order, n) + } + + var roots []*procNode + for _, n := range order { + parent, ok := nodes[n.proc.ParentPID] + if !ok || parent == n { + roots = append(roots, n) + continue + } + parent.children = append(parent.children, n) + } + + sortNodes := func(ns []*procNode) { + sort.Slice(ns, func(i, j int) bool { return ns[i].proc.PID < ns[j].proc.PID }) + } + sortNodes(roots) + for _, n := range order { + sortNodes(n.children) + } + + // Detached cycles (e.g. A→B→A with no root ancestor) never reach a root. + // Promote each still-unreachable node to a root and sever its parent + // edge, which breaks the cycle so tree walks terminate. + reachable := make(map[*procNode]bool) + var mark func(n *procNode) + mark = func(n *procNode) { + if reachable[n] { + return + } + reachable[n] = true + for _, c := range n.children { + mark(c) + } + } + for _, r := range roots { + mark(r) + } + for _, n := range order { + if reachable[n] { + continue + } + if parent, ok := nodes[n.proc.ParentPID]; ok { + for i, c := range parent.children { + if c == n { + parent.children = append(parent.children[:i], parent.children[i+1:]...) + break + } + } + } + roots = append(roots, n) + mark(n) + } + return roots +} + +// renderProcTree flattens the nested tree into display lines, indented two +// spaces per depth level. +func renderProcTree(roots []*procNode) []string { + var lines []string + var walk func(n *procNode, depth int) + walk = func(n *procNode, depth int) { + name := n.proc.ProcessName + if name == "" { + name = n.proc.ImagePath + } + line := fmt.Sprintf("%s└─ %s %s", strings.Repeat(" ", depth), name, styleDim.Render("("+n.proc.PID+")")) + if det := n.proc.Detection; det != "" && det != "clean" { + line += " " + detectStyle.Render("["+det+"]") + } + lines = append(lines, line) + for _, c := range n.children { + walk(c, depth+1) + } + } + for _, r := range roots { + walk(r, 0) + } + return lines +} + +// printProcessTree renders the nested process tree. +func printProcessTree(procs []entity.Process) { + if len(procs) == 0 { + return + } + fmt.Println(headerStyle.Render(fmt.Sprintf("Process Tree (%d)", len(procs)))) + for _, line := range renderProcTree(buildProcTree(procs)) { + fmt.Println(" " + line) + } + fmt.Println() +} + +// behaviorRunDuration formats the wall-clock duration of a run, or "-" when +// timing data is incomplete. +func behaviorRunDuration(sum entity.BehaviorReportSummary) string { + if sum.FinishedAt > sum.StartedAt && sum.StartedAt != 0 { + return (time.Duration(sum.FinishedAt-sum.StartedAt) * time.Second).String() + } + return "-" +} + +// printBehaviorRunsTable lists every sandbox run of the file, newest first. +// The run displayed in detail below is marked with ▸. +func printBehaviorRunsTable(reports map[string]entity.BehaviorReportSummary, selectedID string) { + type run struct { + id string + sum entity.BehaviorReportSummary + } + runs := make([]run, 0, len(reports)) + for id, sum := range reports { + runs = append(runs, run{id: id, sum: sum}) + } + sort.Slice(runs, func(i, j int) bool { + ti, tj := behaviorRunTime(runs[i].sum), behaviorRunTime(runs[j].sum) + if ti != tj { + return ti > tj + } + return runs[i].id > runs[j].id + }) + + osCol := lipgloss.NewStyle().Width(30) + timeCol := lipgloss.NewStyle().Width(24) + durCol := lipgloss.NewStyle().Width(9) + rulesCol := lipgloss.NewStyle().Width(6) + artCol := lipgloss.NewStyle().Width(10) + shotsCol := lipgloss.NewStyle().Width(6) + + fmt.Println(headerStyle.Render(fmt.Sprintf("Sandbox Runs (%d)", len(runs)))) + fmt.Printf(" %s %s %s %s %s %s %s %s %s\n", + styleDim.Render(fmt.Sprintf("%-36s", "ID")), + styleDim.Render(fmt.Sprintf("%-10s", "STATUS")), + styleDim.Render(osCol.Render("OS/PROFILE")), + styleDim.Render(timeCol.Render("TIME")), + styleDim.Render(durCol.Render("DURATION")), + styleDim.Render(rulesCol.Render("RULES")), + styleDim.Render(artCol.Render("ARTIFACTS")), + styleDim.Render(shotsCol.Render("SHOTS")), + styleDim.Render("FAILURE"), + ) + fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 144))) + + for _, r := range runs { + marker := " " + if r.id == selectedID { + marker = titleStyle.Render("▸") + } + + // The sandbox pipeline resolves the requested OS into a digest-bound + // guest profile; older events may carry only one of the two. + osName := r.sum.ScanConfig.OS + if osName == "" { + osName = r.sum.ScanConfig.ProfileID + } + if osName == "" { + osName = "-" + } + runTime := "-" + if ts := behaviorRunTime(r.sum); ts != 0 { + runTime = formatTimestamp(ts) + } + failure := "" + if r.sum.Failure != nil { + failure = detectStyle.Render(r.sum.Failure.Class) + } + + fmt.Printf(" %s %s %s %s %s %s %s %s %s %s\n", + marker, + r.id, + renderPaddedBehaviorStatus(r.sum.Status), + osCol.Render(osName), + timeCol.Render(runTime), + durCol.Render(behaviorRunDuration(r.sum)), + rulesCol.Render(fmt.Sprintf("%d", r.sum.Evidence.TotalRules)), + artCol.Render(fmt.Sprintf("%d", r.sum.ArtifactCount)), + shotsCol.Render(fmt.Sprintf("%d", r.sum.ScreenshotsCount)), + failure, + ) + } + if len(runs) > 1 { + fmt.Println(" " + styleDim.Render("Use --behavior-id to view a specific run.")) + } + fmt.Println() +} diff --git a/cmd/view_test.go b/cmd/view_test.go new file mode 100644 index 0000000..c1378ea --- /dev/null +++ b/cmd/view_test.go @@ -0,0 +1,203 @@ +// 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 ( + "strings" + "testing" + + "github.com/saferwall/cli/internal/entity" +) + +func summary(status string, finishedAt int64) entity.BehaviorReportSummary { + return entity.BehaviorReportSummary{Status: status, FinishedAt: finishedAt} +} + +func TestSelectBehaviorID(t *testing.T) { + reports := map[string]entity.BehaviorReportSummary{ + "aaa": summary(entity.BehaviorStatusCompleted, 100), + "bbb": summary(entity.BehaviorStatusCompleted, 200), + "ccc": summary(entity.BehaviorStatusFailed, 300), + } + + tests := []struct { + name string + reports map[string]entity.BehaviorReportSummary + defaultID string + requested string + want string + wantErr bool + }{ + { + name: "requested wins regardless of status", + reports: reports, defaultID: "aaa", requested: "ccc", + want: "ccc", + }, + { + name: "requested not found is an error", + reports: reports, defaultID: "aaa", requested: "zzz", + wantErr: true, + }, + { + name: "completed default wins over newer completed run", + reports: reports, defaultID: "aaa", + want: "aaa", + }, + { + name: "non-completed default falls back to newest completed", + reports: reports, defaultID: "ccc", + want: "bbb", + }, + { + name: "missing default falls back to newest completed", + reports: reports, defaultID: "", + want: "bbb", + }, + { + name: "started_at breaks ties when finished_at is zero", + reports: map[string]entity.BehaviorReportSummary{ + "old": {Status: entity.BehaviorStatusCompleted, StartedAt: 10}, + "new": {Status: entity.BehaviorStatusCompleted, StartedAt: 20}, + }, + want: "new", + }, + { + name: "no completed run selects nothing", + reports: map[string]entity.BehaviorReportSummary{ + "aaa": summary(entity.BehaviorStatusFailed, 100), + "bbb": summary(entity.BehaviorStatusQueued, 0), + }, + defaultID: "aaa", + want: "", + }, + { + name: "empty map selects nothing", + reports: map[string]entity.BehaviorReportSummary{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := selectBehaviorID(tt.reports, tt.defaultID, tt.requested) + if (err != nil) != tt.wantErr { + t.Fatalf("selectBehaviorID() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("selectBehaviorID() = %q, want %q", got, tt.want) + } + }) + } +} + +func proc(pid, ppid, name string) entity.Process { + return entity.Process{PID: pid, ParentPID: ppid, ProcessName: name} +} + +func treePIDs(roots []*procNode) []string { + var pids []string + var walk func(n *procNode) + walk = func(n *procNode) { + pids = append(pids, n.proc.PID) + for _, c := range n.children { + walk(c) + } + } + for _, r := range roots { + walk(r) + } + return pids +} + +func TestBuildProcTree(t *testing.T) { + t.Run("nests children under parents", func(t *testing.T) { + roots := buildProcTree([]entity.Process{ + proc("0x2", "0x1", "child"), + proc("0x1", "", "root"), + proc("0x3", "0x2", "grandchild"), + }) + if len(roots) != 1 { + t.Fatalf("roots = %d, want 1", len(roots)) + } + got := treePIDs(roots) + want := []string{"0x1", "0x2", "0x3"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("tree order = %v, want %v", got, want) + } + }) + + t.Run("orphan parent becomes root, children sorted by PID", func(t *testing.T) { + roots := buildProcTree([]entity.Process{ + proc("0x5", "0x9", "orphan"), + proc("0x1", "", "root"), + proc("0x3", "0x1", "b"), + proc("0x2", "0x1", "a"), + }) + if len(roots) != 2 { + t.Fatalf("roots = %d, want 2", len(roots)) + } + got := treePIDs(roots) + want := []string{"0x1", "0x2", "0x3", "0x5"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("tree order = %v, want %v", got, want) + } + }) + + t.Run("self-parent and cycles terminate and keep all processes", func(t *testing.T) { + roots := buildProcTree([]entity.Process{ + proc("0x1", "0x1", "self"), + proc("0x2", "0x3", "cycleA"), + proc("0x3", "0x2", "cycleB"), + }) + got := treePIDs(roots) + if len(got) != 3 { + t.Errorf("rendered %d processes, want all 3 (got %v)", len(got), got) + } + }) + + t.Run("duplicate PIDs keep first occurrence", func(t *testing.T) { + roots := buildProcTree([]entity.Process{ + proc("0x1", "", "first"), + proc("0x1", "", "second"), + }) + if len(roots) != 1 || roots[0].proc.ProcessName != "first" { + t.Errorf("roots = %+v, want single node named first", roots) + } + }) +} + +func TestRenderProcTree(t *testing.T) { + roots := buildProcTree([]entity.Process{ + proc("0x1", "", "root.exe"), + proc("0x2", "0x1", "child.exe"), + proc("0x3", "0x2", "grandchild.exe"), + }) + lines := renderProcTree(roots) + if len(lines) != 3 { + t.Fatalf("lines = %d, want 3", len(lines)) + } + wantPrefixes := []string{"└─ root.exe", " └─ child.exe", " └─ grandchild.exe"} + for i, want := range wantPrefixes { + if !strings.HasPrefix(lines[i], want) { + t.Errorf("line %d = %q, want prefix %q", i, lines[i], want) + } + } +} + +func TestRenderProcTreeDetection(t *testing.T) { + p := proc("0x1", "", "evil.exe") + p.Detection = "Emotet" + lines := renderProcTree(buildProcTree([]entity.Process{p})) + if len(lines) != 1 || !strings.Contains(lines[0], "Emotet") { + t.Errorf("lines = %v, want detection name rendered", lines) + } + + clean := proc("0x2", "", "ok.exe") + clean.Detection = "clean" + lines = renderProcTree(buildProcTree([]entity.Process{clean})) + if len(lines) != 1 || strings.Contains(lines[0], "clean") { + t.Errorf("lines = %v, want clean detection omitted", lines) + } +} diff --git a/internal/entity/behavior.go b/internal/entity/behavior.go new file mode 100644 index 0000000..fff742d --- /dev/null +++ b/internal/entity/behavior.go @@ -0,0 +1,99 @@ +// 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 entity + +// Behavior status lifecycle values. +const ( + BehaviorStatusQueued = "queued" + BehaviorStatusProcessing = "processing" + BehaviorStatusCompleted = "completed" + BehaviorStatusPartial = "partial" + BehaviorStatusFailed = "failed" +) + +// BehaviorReportSummary is the bounded per-run summary embedded in a +// file document under `behavior_reports`. +type BehaviorReportSummary struct { + ID string `json:"id"` + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Revision int `json:"revision"` + QueuedAt int64 `json:"queued_at"` + StartedAt int64 `json:"started_at,omitempty"` + FinishedAt int64 `json:"finished_at,omitempty"` + ScanConfig BehaviorScanSummary `json:"scan_config"` + AttemptCount int `json:"attempt_count,omitempty"` + Evidence BehaviorEvidence `json:"evidence"` + ArtifactCount int `json:"artifact_count,omitempty"` + ScreenshotsCount int `json:"screenshots_count,omitempty"` + Failure *BehaviorFailure `json:"failure,omitempty"` +} + +// BehaviorScanSummary is the sandbox configuration subset embedded in a +// file document. +type BehaviorScanSummary struct { + OS string `json:"os,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + Timeout int `json:"timeout"` + Country string `json:"country,omitempty"` + DestPath string `json:"dest_path,omitempty"` +} + +// BehaviorEvidence stores the detection-evidence rank vector of a run. +type BehaviorEvidence struct { + MalwareYARA int `json:"malware_yara"` + HighBehavior int `json:"high_behavior"` + HighOther int `json:"high_other"` + Medium int `json:"medium"` + TotalRules int `json:"total_rules"` + DetectedArtifacts int `json:"detected_artifacts"` +} + +// BehaviorFailure describes the terminal failure of a run, when present. +type BehaviorFailure struct { + Class string `json:"class"` + Stage string `json:"stage,omitempty"` + RetryClass string `json:"retry_class,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Message string `json:"message,omitempty"` +} + +// Behavior is the subset of a behavior document the CLI displays. Fetched +// from /v1/behaviors/{id}/ with an explicit `fields` projection. +type Behavior struct { + Environment BehaviorEnvironment `json:"env"` + Capabilities []Capability `json:"capabilities,omitempty"` + ProcessTree []Process `json:"proc_tree,omitempty"` +} + +// BehaviorEnvironment describes the sandbox used for a run. +type BehaviorEnvironment struct { + SandboxVersion string `json:"sandbox_version,omitempty"` + RunID string `json:"run_id,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + Complete bool `json:"complete,omitempty"` + GuestHealthy bool `json:"guest_healthy,omitempty"` +} + +// Capability is a behavioral technique detected during execution. +type Capability struct { + Description string `json:"description"` + Severity string `json:"severity"` + Category string `json:"category"` + Module string `json:"module"` + RuleID string `json:"rule_id"` + ProcessID string `json:"pid"` +} + +// Process is one node of the detonation process tree. +type Process struct { + ImagePath string `json:"path"` + PID string `json:"pid"` + ParentPID string `json:"parent_pid"` + ParentLink string `json:"parent_link"` + ProcessName string `json:"proc_name"` + FileType string `json:"file_type"` + Detection string `json:"detection"` +} diff --git a/internal/entity/file.go b/internal/entity/file.go index 08ec645..e8cf1ac 100755 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -6,41 +6,42 @@ 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"` + DefaultBehaviorID string `json:"default_behavior_id,omitempty"` + BehaviorReports map[string]BehaviorReportSummary `json:"behavior_reports,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/webapi/behaviors.go b/internal/webapi/behaviors.go new file mode 100644 index 0000000..f9af845 --- /dev/null +++ b/internal/webapi/behaviors.go @@ -0,0 +1,65 @@ +// 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" + "fmt" + "net/http" + "net/url" + "strings" +) + +// GetBehaviorReport fetches selected fields of a behavior document. +// fields must be non-empty: an unfiltered GET inlines the entire API trace, +// which can be enormous. +func (s Service) GetBehaviorReport(id string, fields []string, out any) error { + if len(fields) == 0 { + return fmt.Errorf("fields must not be empty") + } + + query := url.Values{} + query.Set("fields", strings.Join(fields, ",")) + reqURL := s.behaviorsURL + id + "/?" + query.Encode() + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + body, err := s.do(req) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +// CountSysEvents returns the total number of system events of the given +// type (file, registry or network) recorded for a behavior report, using a +// minimal pagination probe. +func (s Service) CountSysEvents(id, eventType string) (int, error) { + query := url.Values{} + query.Set("type", eventType) + query.Set("page", "1") + query.Set("per_page", "1") + reqURL := s.behaviorsURL + id + "/sys-events/?" + query.Encode() + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return 0, err + } + + body, err := s.do(req) + if err != nil { + return 0, err + } + + var pages Pages + if err := json.Unmarshal(body, &pages); err != nil { + return 0, err + } + return pages.TotalCount, nil +} diff --git a/internal/webapi/behaviors_test.go b/internal/webapi/behaviors_test.go new file mode 100644 index 0000000..a1168ef --- /dev/null +++ b/internal/webapi/behaviors_test.go @@ -0,0 +1,132 @@ +// 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" + "strings" + "testing" + + "github.com/saferwall/cli/internal/entity" +) + +const testBehaviorID = "94b40295-fa4c-5de6-89e5-97cff1e5ecfa" + +func TestGetBehaviorReport(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + wantPath := "/v1/behaviors/" + testBehaviorID + "/" + if r.URL.Path != wantPath { + t.Errorf("path = %s, want %s", r.URL.Path, wantPath) + } + if got := r.URL.Query().Get("fields"); got != "env,capabilities" { + t.Errorf("fields = %q, want %q", got, "env,capabilities") + } + + json.NewEncoder(w).Encode(map[string]any{ + "env": map[string]any{"sandbox_version": "1.2.3", "guest_healthy": true}, + "capabilities": []map[string]any{ + {"description": "Creates scheduled task", "severity": "high", "category": "persistence"}, + }, + }) + })) + defer srv.Close() + + svc := New(srv.URL) + var doc entity.Behavior + err := svc.GetBehaviorReport(testBehaviorID, []string{"env", "capabilities"}, &doc) + if err != nil { + t.Fatalf("GetBehaviorReport() error = %v", err) + } + if doc.Environment.SandboxVersion != "1.2.3" { + t.Errorf("sandbox_version = %q, want 1.2.3", doc.Environment.SandboxVersion) + } + if !doc.Environment.GuestHealthy { + t.Error("guest_healthy = false, want true") + } + if len(doc.Capabilities) != 1 || doc.Capabilities[0].Severity != "high" { + t.Errorf("capabilities = %+v, want one high-severity entry", doc.Capabilities) + } +} + +func TestGetBehaviorReportEmptyFields(t *testing.T) { + svc := New("http://unused") + var doc entity.Behavior + if err := svc.GetBehaviorReport(testBehaviorID, nil, &doc); err == nil { + t.Fatal("GetBehaviorReport() with empty fields should error") + } +} + +func TestGetBehaviorReportBadField(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": "field not allowed"}) + })) + defer srv.Close() + + svc := New(srv.URL) + var doc entity.Behavior + err := svc.GetBehaviorReport(testBehaviorID, []string{"bogus"}, &doc) + if err == nil { + t.Fatal("GetBehaviorReport() error = nil, want error") + } + if !strings.Contains(err.Error(), "field not allowed") { + t.Errorf("error = %q, want it to contain the API message", err) + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("error = %q, want it to contain the status code", err) + } +} + +func TestCountSysEvents(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := "/v1/behaviors/" + testBehaviorID + "/sys-events/" + if r.URL.Path != wantPath { + t.Errorf("path = %s, want %s", r.URL.Path, wantPath) + } + q := r.URL.Query() + if got := q.Get("type"); got != "network" { + t.Errorf("type = %q, want network", got) + } + if got := q.Get("page"); got != "1" { + t.Errorf("page = %q, want 1", got) + } + if got := q.Get("per_page"); got != "1" { + t.Errorf("per_page = %q, want 1", got) + } + + json.NewEncoder(w).Encode(map[string]any{ + "page": 1, "per_page": 1, "page_count": 1234, "total_count": 1234, + "items": []map[string]any{{"pid": "0x1", "type": "network", "path": "1.2.3.4", "op": "TCP"}}, + }) + })) + defer srv.Close() + + svc := New(srv.URL) + count, err := svc.CountSysEvents(testBehaviorID, "network") + if err != nil { + t.Fatalf("CountSysEvents() error = %v", err) + } + if count != 1234 { + t.Errorf("count = %d, want 1234", count) + } +} + +func TestCountSysEventsHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "resource not found"}) + })) + defer srv.Close() + + svc := New(srv.URL) + if _, err := svc.CountSysEvents(testBehaviorID, "file"); err == nil { + t.Fatal("CountSysEvents() error = nil, want error") + } +} diff --git a/internal/webapi/service.go b/internal/webapi/service.go index 30f9f19..cd57339 100644 --- a/internal/webapi/service.go +++ b/internal/webapi/service.go @@ -10,20 +10,23 @@ import ( ) const ( - filesEndpoint = "/v1/files/" + filesEndpoint = "/v1/files/" + behaviorsEndpoint = "/v1/behaviors/" defaultTimeout = 5 * time.Minute ) type Service struct { - filesURL string - client *http.Client + filesURL string + behaviorsURL string + client *http.Client } // New generates new web apis service object. func New(baseURL string) Service { return Service{ - client: &http.Client{Timeout: defaultTimeout}, - filesURL: baseURL + filesEndpoint, + client: &http.Client{Timeout: defaultTimeout}, + filesURL: baseURL + filesEndpoint, + behaviorsURL: baseURL + behaviorsEndpoint, } }