From 4ec7e236c3e9a68a49750dfd0f3a2dd8ede0346d Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 17:19:42 +0100 Subject: [PATCH 1/4] feat: show dynamic analysis results in the view command Extend 'view ' with a Dynamic Analysis section sourced from the behavior_reports summaries on the file document and the public /v1/behaviors/{id}/ endpoints: - run status, sandbox environment, timings and failure details - evidence summary and activity counts (sys-events via pagination probes) - capabilities grouped by severity, deduplicated - nested process tree with per-process detections - one-line listing of other runs, selectable via --behavior-id Replace the stale File.BehaviorReportID field with default_behavior_id and the behavior_reports map, matching the current API schema. Detail requests always use ?fields= projection to avoid inlining the full API trace, fan out concurrently, and degrade gracefully when sub-documents or counts are unavailable. --- cmd/view.go | 463 +++++++++++++++++++++++++++++- cmd/view_test.go | 203 +++++++++++++ internal/entity/behavior.go | 99 +++++++ internal/entity/file.go | 71 ++--- internal/webapi/behaviors.go | 65 +++++ internal/webapi/behaviors_test.go | 132 +++++++++ internal/webapi/service.go | 13 +- 7 files changed, 1005 insertions(+), 41 deletions(-) create mode 100644 cmd/view_test.go create mode 100644 internal/entity/behavior.go create mode 100644 internal/webapi/behaviors.go create mode 100644 internal/webapi/behaviors_test.go diff --git a/cmd/view.go b/cmd/view.go index ca0921d..5761a9c 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,456 @@ 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 + } + + if selectedID != "" { + sum := file.BehaviorReports[selectedID] + detail := fetchBehaviorDetail(webSvc, selectedID) + printBehaviorRunDetail(selectedID, sum, detail) + } + + printOtherBehaviorRuns(file.BehaviorReports, selectedID) + 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) + printKV("Status", renderBehaviorStatus(sum.Status)) + if sum.ScanConfig.OS != "" { + printKV("OS", sum.ScanConfig.OS) + } + 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.StartedAt != 0 { + printKV("Started", formatTimestamp(sum.StartedAt)) + } + if sum.FinishedAt != 0 { + printKV("Finished", formatTimestamp(sum.FinishedAt)) + } + if sum.FinishedAt > sum.StartedAt && sum.StartedAt != 0 { + printKV("Duration", (time.Duration(sum.FinishedAt-sum.StartedAt) * time.Second).String()) + } + 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() +} + +// printOtherBehaviorRuns lists the remaining runs as one-liners, newest first. +func printOtherBehaviorRuns(reports map[string]entity.BehaviorReportSummary, selectedID string) { + type run struct { + id string + sum entity.BehaviorReportSummary + } + var others []run + for id, sum := range reports { + if id == selectedID { + continue + } + others = append(others, run{id: id, sum: sum}) + } + if len(others) == 0 { + return + } + + sort.Slice(others, func(i, j int) bool { + ti, tj := behaviorRunTime(others[i].sum), behaviorRunTime(others[j].sum) + if ti != tj { + return ti > tj + } + return others[i].id > others[j].id + }) + + fmt.Println(headerStyle.Render(fmt.Sprintf("Other Runs (%d)", len(others)))) + for _, r := range others { + line := fmt.Sprintf(" %s %s", r.id, renderPaddedBehaviorStatus(r.sum.Status)) + if r.sum.ScanConfig.OS != "" { + line += " " + r.sum.ScanConfig.OS + } + if ts := behaviorRunTime(r.sum); ts != 0 { + line += " " + styleDim.Render(formatTimestamp(ts)) + } + line += fmt.Sprintf(" rules:%d", r.sum.Evidence.TotalRules) + if r.sum.Failure != nil { + line += " " + styleDim.Render(r.sum.Failure.Class) + } + fmt.Println(line) + } + 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, } } From 6f023dce467ffe39a1c1344ea1675e070bb0f6fc Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 18:05:23 +0100 Subject: [PATCH 2/4] fix: default detonation timeout to 30 seconds --- README.md | 2 +- cmd/rescan.go | 2 +- cmd/scan.go | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) 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)") From 3be8a844b9d859d3a0b13e09c3c71e1fdd2910f5 Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 18:09:03 +0100 Subject: [PATCH 3/4] feat: list all sandbox runs in a table in the view command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open the Dynamic Analysis section with a table of every run — status, OS, time, duration, rule/artifact/screenshot counts and failure class — marking the run rendered in detail below with a selector arrow, so users can pick a specific report to display via --behavior-id. Drop the fields from the detail block that the table now covers. --- cmd/view.go | 112 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/cmd/view.go b/cmd/view.go index 5761a9c..dbfd6ab 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -463,13 +463,13 @@ func printDynamicAnalysis(file entity.File, webSvc webapi.Service, requestedID s return nil } + printBehaviorRunsTable(file.BehaviorReports, selectedID) + if selectedID != "" { sum := file.BehaviorReports[selectedID] detail := fetchBehaviorDetail(webSvc, selectedID) printBehaviorRunDetail(selectedID, sum, detail) } - - printOtherBehaviorRuns(file.BehaviorReports, selectedID) return nil } @@ -502,11 +502,7 @@ func renderPaddedBehaviorStatus(status string) string { // 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) - printKV("Status", renderBehaviorStatus(sum.Status)) - if sum.ScanConfig.OS != "" { - printKV("OS", sum.ScanConfig.OS) - } + printKV("Report ID", id+" "+renderBehaviorStatus("("+sum.Status+")")) if sum.ScanConfig.Country != "" { printKV("Country", sum.ScanConfig.Country) } @@ -517,15 +513,6 @@ func printBehaviorRunDetail(id string, sum entity.BehaviorReportSummary, d behav } printKV("Sandbox", sandbox) } - if sum.StartedAt != 0 { - printKV("Started", formatTimestamp(sum.StartedAt)) - } - if sum.FinishedAt != 0 { - printKV("Finished", formatTimestamp(sum.FinishedAt)) - } - if sum.FinishedAt > sum.StartedAt && sum.StartedAt != 0 { - printKV("Duration", (time.Duration(sum.FinishedAt-sum.StartedAt) * time.Second).String()) - } if sum.AttemptCount > 1 { printKV("Attempts", fmt.Sprintf("%d", sum.AttemptCount)) } @@ -756,46 +743,89 @@ func printProcessTree(procs []entity.Process) { fmt.Println() } -// printOtherBehaviorRuns lists the remaining runs as one-liners, newest first. -func printOtherBehaviorRuns(reports map[string]entity.BehaviorReportSummary, selectedID string) { +// 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 } - var others []run + runs := make([]run, 0, len(reports)) for id, sum := range reports { - if id == selectedID { - continue - } - others = append(others, run{id: id, sum: sum}) - } - if len(others) == 0 { - return + runs = append(runs, run{id: id, sum: sum}) } - - sort.Slice(others, func(i, j int) bool { - ti, tj := behaviorRunTime(others[i].sum), behaviorRunTime(others[j].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 others[i].id > others[j].id + return runs[i].id > runs[j].id }) - fmt.Println(headerStyle.Render(fmt.Sprintf("Other Runs (%d)", len(others)))) - for _, r := range others { - line := fmt.Sprintf(" %s %s", r.id, renderPaddedBehaviorStatus(r.sum.Status)) - if r.sum.ScanConfig.OS != "" { - line += " " + r.sum.ScanConfig.OS + osCol := lipgloss.NewStyle().Width(16) + 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")), + 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("─", 130))) + + for _, r := range runs { + marker := " " + if r.id == selectedID { + marker = titleStyle.Render("▸") } + + osName := r.sum.ScanConfig.OS + if osName == "" { + osName = "-" + } + runTime := "-" if ts := behaviorRunTime(r.sum); ts != 0 { - line += " " + styleDim.Render(formatTimestamp(ts)) + runTime = formatTimestamp(ts) } - line += fmt.Sprintf(" rules:%d", r.sum.Evidence.TotalRules) + failure := "" if r.sum.Failure != nil { - line += " " + styleDim.Render(r.sum.Failure.Class) - } - fmt.Println(line) + 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(" " + styleDim.Render("Use --behavior-id to view a specific run.")) fmt.Println() } From 0e7bf1f96b500abfb8a87d2d4f2ce464803e637e Mon Sep 17 00:00:00 2001 From: Ayoub Faouzi Date: Mon, 3 Aug 2026 18:17:44 +0100 Subject: [PATCH 4/4] fix: fall back to guest profile ID when scan config OS is empty The sandbox pipeline resolves the requested OS into a digest-bound guest profile and does not always echo the os field back into the behavior scan config, leaving the OS column empty. Show the profile ID instead, which identifies the guest more precisely. --- cmd/view.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/view.go b/cmd/view.go index dbfd6ab..c6318cf 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -771,7 +771,7 @@ func printBehaviorRunsTable(reports map[string]entity.BehaviorReportSummary, sel return runs[i].id > runs[j].id }) - osCol := lipgloss.NewStyle().Width(16) + osCol := lipgloss.NewStyle().Width(30) timeCol := lipgloss.NewStyle().Width(24) durCol := lipgloss.NewStyle().Width(9) rulesCol := lipgloss.NewStyle().Width(6) @@ -782,7 +782,7 @@ func printBehaviorRunsTable(reports map[string]entity.BehaviorReportSummary, sel 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")), + styleDim.Render(osCol.Render("OS/PROFILE")), styleDim.Render(timeCol.Render("TIME")), styleDim.Render(durCol.Render("DURATION")), styleDim.Render(rulesCol.Render("RULES")), @@ -790,7 +790,7 @@ func printBehaviorRunsTable(reports map[string]entity.BehaviorReportSummary, sel styleDim.Render(shotsCol.Render("SHOTS")), styleDim.Render("FAILURE"), ) - fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 130))) + fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 144))) for _, r := range runs { marker := " " @@ -798,7 +798,12 @@ func printBehaviorRunsTable(reports map[string]entity.BehaviorReportSummary, sel 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 = "-" }