Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions cmd/logira/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,26 @@ 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
}
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 <subcommand> help`
if len(args) > 0 && args[0] == "help" {
Expand Down
29 changes: 29 additions & 0 deletions cmd/logira/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 11 additions & 0 deletions internal/cli/exit.go
Original file line number Diff line number Diff line change
@@ -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)
}
26 changes: 13 additions & 13 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -190,30 +195,23 @@ 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)
}
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
}
Expand Down Expand Up @@ -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/<run-id>/ (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)

Expand Down
185 changes: 185 additions & 0 deletions internal/cli/run_summary.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading