From 2ccfe29fc6c085e61bca5d8db311d8f7103b3007 Mon Sep 17 00:00:00 2001 From: melonattacker <41631269+melonattacker@users.noreply.github.com> Date: Tue, 12 May 2026 22:50:17 +0900 Subject: [PATCH] feat: implement end-of-run summary for logira run --- cmd/logira/main.go | 15 ++- cmd/logira/main_test.go | 29 +++++ internal/cli/exit.go | 11 ++ internal/cli/run.go | 26 ++--- internal/cli/run_summary.go | 185 +++++++++++++++++++++++++++++++ internal/cli/run_summary_test.go | 155 ++++++++++++++++++++++++++ 6 files changed, 406 insertions(+), 15 deletions(-) create mode 100644 cmd/logira/main_test.go create mode 100644 internal/cli/exit.go create mode 100644 internal/cli/run_summary.go create mode 100644 internal/cli/run_summary_test.go diff --git a/cmd/logira/main.go b/cmd/logira/main.go index 5d95e08..4178aa8 100644 --- a/cmd/logira/main.go +++ b/cmd/logira/main.go @@ -63,8 +63,8 @@ func realMain() int { } if err != nil { - if errors.Is(err, flag.ErrHelp) { - return 0 + if code, ok := commandExitCode(err); ok { + return code } fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 @@ -72,6 +72,17 @@ func realMain() int { return 0 } +func commandExitCode(err error) (int, bool) { + if errors.Is(err, flag.ErrHelp) { + return 0, true + } + var exitErr *cli.ExitCodeError + if errors.As(err, &exitErr) { + return exitErr.Code, true + } + return 0, false +} + func normalizeSubcommandHelpArgs(args []string) []string { // Support: `logira help` if len(args) > 0 && args[0] == "help" { diff --git a/cmd/logira/main_test.go b/cmd/logira/main_test.go new file mode 100644 index 0000000..9a9dbe2 --- /dev/null +++ b/cmd/logira/main_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "testing" + + "github.com/melonattacker/logira/internal/cli" +) + +func TestCommandExitCode(t *testing.T) { + if got, ok := commandExitCode(flag.ErrHelp); !ok || got != 0 { + t.Fatalf("help got code=%d ok=%v, want code=0 ok=true", got, ok) + } + + if got, ok := commandExitCode(&cli.ExitCodeError{Code: 42}); !ok || got != 42 { + t.Fatalf("exit error got code=%d ok=%v, want code=42 ok=true", got, ok) + } + + wrapped := fmt.Errorf("wrapped: %w", &cli.ExitCodeError{Code: 17}) + if got, ok := commandExitCode(wrapped); !ok || got != 17 { + t.Fatalf("wrapped exit error got code=%d ok=%v, want code=17 ok=true", got, ok) + } + + if got, ok := commandExitCode(errors.New("other")); ok || got != 0 { + t.Fatalf("other error got code=%d ok=%v, want code=0 ok=false", got, ok) + } +} diff --git a/internal/cli/exit.go b/internal/cli/exit.go new file mode 100644 index 0000000..3e02c71 --- /dev/null +++ b/internal/cli/exit.go @@ -0,0 +1,11 @@ +package cli + +import "fmt" + +type ExitCodeError struct { + Code int +} + +func (e *ExitCodeError) Error() string { + return fmt.Sprintf("audited command exited with code %d", e.Code) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index cd88ae6..a4da32b 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -3,7 +3,6 @@ package cli import ( "bytes" "context" - "encoding/json" "errors" "flag" "fmt" @@ -50,10 +49,12 @@ func RunCommand(ctx context.Context, args []string) error { var hashMaxBytes int64 var waitChildren bool var waitChildrenTimeout time.Duration + var summaryModeS string fs.StringVar(&logPath, "log", "", "deprecated: optional extra copy of events.jsonl written to this path") fs.StringVar(&tool, "tool", "", "tool name for run id suffix (default: basename of the command)") fs.StringVar(&rulesPath, "rules", "", "path to custom detection rules YAML (appended to built-in rules)") + fs.StringVar(&summaryModeS, "summary", string(runSummaryModeAuto), "end-of-run summary: auto|off|detections") fs.Var(&watch, "watch", "deprecated compatibility flag; file event retention is rule-driven") fs.BoolVar(&enableExec, "exec", true, "enable exec tracing") fs.BoolVar(&enableFile, "file", true, "enable file tracing") @@ -67,6 +68,10 @@ func RunCommand(ctx context.Context, args []string) error { if err := fs.Parse(flagArgs); err != nil { return err } + summaryMode, err := parseRunSummaryMode(summaryModeS) + if err != nil { + return err + } if sep == -1 { fs.Usage() @@ -190,21 +195,10 @@ func RunCommand(ctx context.Context, args []string) error { stopErr := stopRunWithRetry(client, startResp.SessionID, exitCode) - // Best-effort: read meta for suspicious_count. - sus := 0 - if b, err := os.ReadFile(runs.MetaPath(startResp.RunDir)); err == nil { - var m runs.Meta - if json.Unmarshal(b, &m) == nil { - sus = m.SuspiciousCount - } - } - if strings.TrimSpace(logPath) != "" { _ = copyFile(filepath.Join(startResp.RunDir, "events.jsonl"), logPath) } - fmt.Fprintf(os.Stderr, "run_id=%s dir=%s suspicious=%d\n", runID, startResp.RunDir, sus) - if stopErr != nil { if waitErr != nil { return fmt.Errorf("%w (also failed to finalize run: %w)", waitErr, stopErr) @@ -212,8 +206,12 @@ func RunCommand(ctx context.Context, args []string) error { return fmt.Errorf("finalize run: %w", stopErr) } + if err := renderRunEndSummaryFromRun(os.Stderr, summaryMode, runID, startResp.RunDir, exitCode); err != nil { + fmt.Fprintf(os.Stderr, "[logira] run %s summary unavailable: %v\n", runID, err) + } + if waitErr != nil { - return waitErr + return &ExitCodeError{Code: exitCode} } return nil } @@ -272,12 +270,14 @@ func runUsage(w io.Writer, fs *flag.FlagSet) { _, _ = fmt.Fprintln(w, " Requires logirad (root daemon) to be running.") _, _ = fmt.Fprintln(w, " Use '--' to separate logira flags from the audited command.") _, _ = fmt.Fprintln(w, " Runs are stored under ~/.logira/runs// (override: LOGIRA_HOME).") + _, _ = fmt.Fprintln(w, " End-of-run summaries are written to stderr; use --summary off to suppress them.") _, _ = fmt.Fprintln(w, " --rules appends a user YAML ruleset to the built-in detection rules for this run.") _, _ = fmt.Fprintln(w, " File event retention is rule-driven; --watch is deprecated compatibility only.") _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintln(w, "Examples:") _, _ = fmt.Fprintf(w, " %s run -- bash -lc 'echo hi > x.txt; curl -s https://example.com >/dev/null'\n", prog) + _, _ = fmt.Fprintf(w, " %s run --summary detections -- claude\n", prog) _, _ = fmt.Fprintf(w, " %s run --rules ./my-rules.yaml -- bash -lc 'cat ~/.aws/credentials >/dev/null'\n", prog) _, _ = fmt.Fprintf(w, " %s run --exec=false --file=true --net=false -- bash -lc 'echo hi > x.txt'\n\n", prog) diff --git a/internal/cli/run_summary.go b/internal/cli/run_summary.go new file mode 100644 index 0000000..0c4e5de --- /dev/null +++ b/internal/cli/run_summary.go @@ -0,0 +1,185 @@ +package cli + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/melonattacker/logira/internal/cliui" + "github.com/melonattacker/logira/internal/storage" +) + +type runSummaryMode string + +const ( + runSummaryModeAuto runSummaryMode = "auto" + runSummaryModeOff runSummaryMode = "off" + runSummaryModeDetections runSummaryMode = "detections" +) + +type runEndSummary struct { + RunID string + Duration string + ExitCode int + EventCounts map[storage.EventType]int + SeverityCounts map[string]int + Risk string + TopDetections []storage.GroupedDetection +} + +func parseRunSummaryMode(v string) (runSummaryMode, error) { + switch runSummaryMode(strings.ToLower(strings.TrimSpace(v))) { + case runSummaryModeAuto: + return runSummaryModeAuto, nil + case runSummaryModeOff: + return runSummaryModeOff, nil + case runSummaryModeDetections: + return runSummaryModeDetections, nil + default: + return "", fmt.Errorf("--summary must be one of auto, off, detections") + } +} + +func renderRunEndSummaryFromRun(w io.Writer, mode runSummaryMode, runID, runDir string, exitCode int) error { + if mode == runSummaryModeOff { + return nil + } + s, err := loadRunEndSummary(runID, runDir, exitCode) + if err != nil { + return err + } + return renderRunEndSummary(w, mode, s) +} + +func loadRunEndSummary(runID, runDir string, exitCode int) (runEndSummary, error) { + sqlite, err := storage.OpenSQLiteReadOnly(filepath.Join(runDir, "index.sqlite")) + if err != nil { + return runEndSummary{}, err + } + defer func() { + _ = sqlite.Close() + }() + + row, err := sqlite.GetRunRow(runID) + if err != nil { + return runEndSummary{}, err + } + eventCounts, err := sqlite.CountEventsByType(runID) + if err != nil { + return runEndSummary{}, err + } + sevCounts, err := sqlite.CountDetectionsBySeverity(runID) + if err != nil { + return runEndSummary{}, err + } + top, err := sqlite.ListGroupedDetections(runID, 5) + if err != nil { + return runEndSummary{}, err + } + + sevCounts = normalizeSeverityCounts(sevCounts) + duration := "unknown" + if row.StartTS > 0 && row.EndTS > 0 && row.EndTS >= row.StartTS { + duration = cliui.FormatDuration(row.StartTS, row.EndTS) + } + + return runEndSummary{ + RunID: runID, + Duration: duration, + ExitCode: exitCode, + EventCounts: eventCounts, + SeverityCounts: sevCounts, + Risk: runRisk(sevCounts), + TopDetections: top, + }, nil +} + +func normalizeSeverityCounts(in map[string]int) map[string]int { + out := map[string]int{"info": 0, "low": 0, "medium": 0, "high": 0} + for k, v := range in { + out[strings.ToLower(strings.TrimSpace(k))] = v + } + return out +} + +func runRisk(sev map[string]int) string { + sev = normalizeSeverityCounts(sev) + switch { + case sev["high"] > 0: + return "HIGH" + case sev["medium"] > 0: + return "MEDIUM" + case sev["low"]+sev["info"] > 0: + return "LOW" + default: + return "NONE" + } +} + +func renderRunEndSummary(w io.Writer, mode runSummaryMode, s runEndSummary) error { + if mode == runSummaryModeOff { + return nil + } + sev := normalizeSeverityCounts(s.SeverityCounts) + risk := strings.TrimSpace(s.Risk) + if risk == "" { + risk = runRisk(sev) + } + + if _, err := fmt.Fprintf(w, "[logira] run %s finished in %s, exit=%d\n", s.RunID, s.Duration, s.ExitCode); err != nil { + return err + } + if mode == runSummaryModeAuto { + evt := s.EventCounts + if evt == nil { + evt = map[storage.EventType]int{} + } + if _, err := fmt.Fprintf(w, " events: %d exec, %d file, %d net\n", evt[storage.TypeExec], evt[storage.TypeFile], evt[storage.TypeNet]); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, " detections: %d high, %d medium, %d low, %d info\n", sev["high"], sev["medium"], sev["low"], sev["info"]); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " risk: %s\n\n", risk); err != nil { + return err + } + + if _, err := fmt.Fprintln(w, " top detections:"); err != nil { + return err + } + if len(s.TopDetections) == 0 { + if _, err := fmt.Fprintln(w, " (none)"); err != nil { + return err + } + } else { + for _, d := range s.TopDetections { + msg := cliui.Truncate(strings.TrimSpace(d.Message), 72) + if d.Count > 1 { + msg = fmt.Sprintf("%s (x%d)", msg, d.Count) + } + if _, err := fmt.Fprintf(w, " %-6s %-8s %s\n", displayDetectionSeverity(d.Severity), cliui.Truncate(d.RuleID, 8), msg); err != nil { + return err + } + } + } + + _, err := fmt.Fprintf(w, "\n next:\n %s view %s\n %s explain %s --show-related\n", progName(), s.RunID, progName(), s.RunID) + return err +} + +func displayDetectionSeverity(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "high": + return "HIGH" + case "medium": + return "MED" + case "low": + return "LOW" + case "info": + return "INFO" + default: + return strings.ToUpper(strings.TrimSpace(v)) + } +} diff --git a/internal/cli/run_summary_test.go b/internal/cli/run_summary_test.go new file mode 100644 index 0000000..aae39e3 --- /dev/null +++ b/internal/cli/run_summary_test.go @@ -0,0 +1,155 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/melonattacker/logira/internal/storage" +) + +func TestLoadRunEndSummaryRiskLevels(t *testing.T) { + tests := []struct { + name string + severities []string + wantRisk string + }{ + {name: "no detections", wantRisk: "NONE"}, + {name: "info only", severities: []string{"info"}, wantRisk: "LOW"}, + {name: "low only", severities: []string{"low"}, wantRisk: "LOW"}, + {name: "medium only", severities: []string{"medium"}, wantRisk: "MEDIUM"}, + {name: "high wins", severities: []string{"low", "medium", "high"}, wantRisk: "HIGH"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runID, runDir := seedRunSummaryDB(t, tt.severities...) + + s, err := loadRunEndSummary(runID, runDir, 0) + if err != nil { + t.Fatal(err) + } + if s.Risk != tt.wantRisk { + t.Fatalf("risk=%q want %q", s.Risk, tt.wantRisk) + } + if s.EventCounts[storage.TypeExec] != 1 || s.EventCounts[storage.TypeFile] != 1 || s.EventCounts[storage.TypeNet] != 1 { + t.Fatalf("unexpected event counts: %#v", s.EventCounts) + } + if s.SeverityCounts["info"]+s.SeverityCounts["low"]+s.SeverityCounts["medium"]+s.SeverityCounts["high"] != len(tt.severities) { + t.Fatalf("unexpected severity counts: %#v", s.SeverityCounts) + } + }) + } +} + +func TestRenderRunEndSummaryModes(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"logira"} + t.Cleanup(func() { + os.Args = oldArgs + }) + + s := runEndSummary{ + RunID: "20260507-143022-claude", + Duration: "2s", + ExitCode: 42, + EventCounts: map[storage.EventType]int{ + storage.TypeExec: 3, + storage.TypeFile: 4, + storage.TypeNet: 5, + }, + SeverityCounts: map[string]int{"high": 1, "medium": 2, "low": 3, "info": 4}, + Risk: "HIGH", + TopDetections: []storage.GroupedDetection{ + {Severity: "high", RuleID: "F021", Message: "read aws credentials/config", Count: 2}, + }, + } + + var auto bytes.Buffer + if err := renderRunEndSummary(&auto, runSummaryModeAuto, s); err != nil { + t.Fatal(err) + } + autoOut := auto.String() + for _, want := range []string{ + "[logira] run 20260507-143022-claude finished in 2s, exit=42", + "events: 3 exec, 4 file, 5 net", + "detections: 1 high, 2 medium, 3 low, 4 info", + "risk: HIGH", + "HIGH F021", + "logira view 20260507-143022-claude", + "logira explain 20260507-143022-claude --show-related", + } { + if !strings.Contains(autoOut, want) { + t.Fatalf("auto output missing %q:\n%s", want, autoOut) + } + } + + var detections bytes.Buffer + if err := renderRunEndSummary(&detections, runSummaryModeDetections, s); err != nil { + t.Fatal(err) + } + detOut := detections.String() + if strings.Contains(detOut, "events:") { + t.Fatalf("detections mode should omit events line:\n%s", detOut) + } + if !strings.Contains(detOut, "exit=42") || !strings.Contains(detOut, "risk: HIGH") { + t.Fatalf("detections output missing exit/risk:\n%s", detOut) + } + + var off bytes.Buffer + if err := renderRunEndSummary(&off, runSummaryModeOff, s); err != nil { + t.Fatal(err) + } + if off.Len() != 0 { + t.Fatalf("off mode wrote output: %q", off.String()) + } +} + +func seedRunSummaryDB(t *testing.T, severities ...string) (string, string) { + t.Helper() + runID := "20260507-143022-test" + runDir := t.TempDir() + db, err := storage.OpenSQLite(filepath.Join(runDir, "index.sqlite")) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = db.Close() + }() + + if err := db.InsertRun(storage.RunRow{ + ID: runID, + StartTS: 1_000_000_000, + EndTS: 3_000_000_000, + Command: "bash -lc true", + Tool: "bash", + }); err != nil { + t.Fatal(err) + } + events := []storage.EventRow{ + {RunID: runID, Seq: 1, TS: 1_100_000_000, Type: string(storage.TypeExec), Summary: "exec bash", DataJSON: `{"filename":"/usr/bin/bash"}`}, + {RunID: runID, Seq: 2, TS: 1_200_000_000, Type: string(storage.TypeFile), Summary: "file modify x.txt", DataJSON: `{"op":"modify","path":"x.txt"}`, Path: "x.txt"}, + {RunID: runID, Seq: 3, TS: 1_300_000_000, Type: string(storage.TypeNet), Summary: "net connect 1.2.3.4:443", DataJSON: `{"op":"connect","dst_ip":"1.2.3.4","dst_port":443}`, DstIP: "1.2.3.4", DstPort: 443}, + } + for _, ev := range events { + if err := db.InsertEvent(ev); err != nil { + t.Fatal(err) + } + } + for i, sev := range severities { + if err := db.InsertDetection(storage.DetectionRow{ + RunID: runID, + Seq: int64(4 + i), + TS: int64(1_400_000_000 + i), + RuleID: "R001", + Severity: sev, + Message: sev + " detection", + RelatedSeq: 1, + }); err != nil { + t.Fatal(err) + } + } + return runID, runDir +}