diff --git a/README.md b/README.md index 302d5f0..063f775 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Authentication is handled automatically - JWT tokens are fetched on connect and | `manager` | Manager info, daemon status, logs | | `alerts` | List alerts, search, heatmap, real-time watch mode | | `rules` | Browse and inspect Wazuh rules | +| `rootcheck` | Rootcheck scan results, trigger scan, clear database | | `sca` | SCA policy results and check details | | `vuln` | Vulnerability inventory per agent | | `syscollector` | Hardware, OS, packages, ports, processes, network | diff --git a/cmd/cmd_help.go b/cmd/cmd_help.go index 1ae9e4c..6711396 100644 --- a/cmd/cmd_help.go +++ b/cmd/cmd_help.go @@ -118,6 +118,12 @@ var subHelp = map[string][]struct{ sub, desc string }{ {"software", "List malware and tools [--search NAME] [--limit N] [--page N]"}, {"show ", "Show full detail for a technique (T1059, T1059.001, ...)"}, }, + "rootcheck": { + {"results ", "List rootcheck results [--status passed|failed] [--search TEXT] [--limit N] [--page N]"}, + {"last ", "Show date of last rootcheck scan"}, + {"scan ", "Trigger a rootcheck scan on an agent"}, + {"clear ", "Wipe the rootcheck database for an agent (irreversible)"}, + }, "rules": { {"list", "List rules [--level N] [--group G] [--limit N]"}, {"get ", "Get details for a specific rule"}, diff --git a/cmd/cmd_rootcheck.go b/cmd/cmd_rootcheck.go new file mode 100644 index 0000000..0960f15 --- /dev/null +++ b/cmd/cmd_rootcheck.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "flag" + "fmt" + "strings" + + "github.com/fatih/color" + + "github.com/0xbbuddha/wazuh-cli/internal/api" + "github.com/0xbbuddha/wazuh-cli/internal/output" +) + +func handleRootcheck(args []string) { + if len(args) == 0 { + handleHelp([]string{"rootcheck"}) + return + } + switch args[0] { + case "results": + rootcheckResults(args[1:]) + case "last": + rootcheckLast(args[1:]) + case "scan": + rootcheckScan(args[1:]) + case "clear": + rootcheckClear(args[1:]) + default: + printUnknownSub("rootcheck", args[0]) + } +} + +func rootcheckResults(args []string) { + if !needsManager() { + return + } + if len(args) == 0 { + printErr(fmt.Errorf("usage: rootcheck results [--status passed|failed] [--search TEXT] [--limit N] [--page N]")) + return + } + agentID := args[0] + + fs := flag.NewFlagSet("rootcheck results", flag.ContinueOnError) + status := fs.String("status", "", "Filter by status: passed or failed") + search := fs.String("search", "", "Filter by event text (substring)") + limit := fs.Int("limit", 30, "Results per page") + page := fs.Int("page", 1, "Page number") + outFmt := fs.String("o", "table", "Output format: table or json") + if err := fs.Parse(args[1:]); err != nil { + return + } + output.Format = *outFmt + offset := (*page - 1) * *limit + + rc := api.NewRootcheckAPI(managerClient) + results, total, err := rc.Results(agentID, *status, *search, *limit, offset) + if err != nil { + printErr(err) + return + } + if output.Format == "json" { + output.JSON(results) + return + } + + totalPages := (total + *limit - 1) / *limit + if totalPages == 0 { + totalPages = 1 + } + output.ShowCount(len(results), total, fmt.Sprintf("rootcheck results - page %d/%d", *page, totalPages)) + + t := output.NewTable("STATUS", "EVENT", "CIS", "DATE") + for _, r := range results { + t.Row( + colorRootcheckStatus(r.Status), + output.Truncate(strings.TrimPrefix(r.Event, "System Audit: "), 60), + output.Dim(output.Truncate(r.CIS, 20)), + output.Dim(output.Truncate(r.DateLast, 19)), + ) + } + t.Flush() + + if *page < totalPages { + color.New(color.Faint).Printf("\n --page %d/%d (--limit %d to change page size)\n\n", *page, totalPages, *limit) + } +} + +func rootcheckLast(args []string) { + if !needsManager() { + return + } + if len(args) == 0 { + printErr(fmt.Errorf("usage: rootcheck last ")) + return + } + + rc := api.NewRootcheckAPI(managerClient) + scan, err := rc.LastScan(args[0]) + if err != nil { + printErr(err) + return + } + + printSection("Last Rootcheck Scan - Agent " + args[0]) + if scan == nil || (scan.Start == "" && scan.End == "") { + printWarn("No scan recorded for this agent") + return + } + output.Field("Start", scan.Start) + output.Field("End", scan.End) + if scan.Start != "" && scan.End != "" { + printOK("Scan completed") + } else if scan.Start != "" { + printWarn("Scan in progress or incomplete") + } +} + +func rootcheckScan(args []string) { + if !needsManager() { + return + } + if len(args) == 0 { + printErr(fmt.Errorf("usage: rootcheck scan ")) + return + } + agentID := args[0] + + if !promptConfirm(fmt.Sprintf("Trigger rootcheck scan on agent %s?", agentID)) { + return + } + + rc := api.NewRootcheckAPI(managerClient) + if err := rc.Scan(agentID); err != nil { + printErr(err) + return + } + printOK(fmt.Sprintf("Rootcheck scan triggered on agent %s", agentID)) +} + +func rootcheckClear(args []string) { + if !needsManager() { + return + } + if len(args) == 0 { + printErr(fmt.Errorf("usage: rootcheck clear ")) + return + } + agentID := args[0] + + if !promptConfirm(fmt.Sprintf("Clear rootcheck database for agent %s? This cannot be undone.", agentID)) { + return + } + + rc := api.NewRootcheckAPI(managerClient) + if err := rc.Clear(agentID); err != nil { + printErr(err) + return + } + printOK(fmt.Sprintf("Rootcheck database cleared for agent %s", agentID)) +} + +func colorRootcheckStatus(s string) string { + switch strings.ToLower(s) { + case "passed": + return color.GreenString(s) + case "failed": + return color.RedString(s) + default: + return s + } +} diff --git a/cmd/dispatch.go b/cmd/dispatch.go index edc70ef..842b601 100644 --- a/cmd/dispatch.go +++ b/cmd/dispatch.go @@ -31,6 +31,7 @@ func init() { "manager": {"Manager information and status", handleManager}, "mitre": {"Browse MITRE ATT&CK techniques, tactics, groups and software", handleMitre}, "status": {"Quick overview: manager, agents, indexer", handleStatus}, + "rootcheck": {"Rootcheck scan results and management", handleRootcheck}, "rules": {"Browse detection rules", handleRules}, "sca": {"Security Configuration Assessment results", handleSCA}, "syscollector": {"System inventory for an agent", handleSyscollector}, diff --git a/cmd/repl.go b/cmd/repl.go index 136ca16..33c9332 100644 --- a/cmd/repl.go +++ b/cmd/repl.go @@ -101,6 +101,12 @@ func buildCompleter() *readline.PrefixCompleter { readline.PcItem("status"), readline.PcItem("logs"), ), + readline.PcItem("rootcheck", + readline.PcItem("results"), + readline.PcItem("last"), + readline.PcItem("scan"), + readline.PcItem("clear"), + ), readline.PcItem("rules", readline.PcItem("list"), readline.PcItem("get"), diff --git a/internal/api/rootcheck.go b/internal/api/rootcheck.go new file mode 100644 index 0000000..b3eee48 --- /dev/null +++ b/internal/api/rootcheck.go @@ -0,0 +1,64 @@ +package api + +import ( + "fmt" + "net/url" + + "github.com/0xbbuddha/wazuh-cli/internal/client" +) + +type RootcheckAPI struct { + c *client.Client +} + +func NewRootcheckAPI(c *client.Client) *RootcheckAPI { + return &RootcheckAPI{c: c} +} + +func (r *RootcheckAPI) Results(agentID, status, search string, limit, offset int) ([]RootcheckResult, int, error) { + p := url.Values{} + p.Set("limit", fmt.Sprintf("%d", limit)) + if offset > 0 { + p.Set("offset", fmt.Sprintf("%d", offset)) + } + if status != "" { + p.Set("status", status) + } + if search != "" { + p.Set("search", search) + } + p.Set("sort", "-date_last") + + var resp APIResponse[RootcheckResult] + if err := r.c.Get(fmt.Sprintf("/rootcheck/%s?%s", agentID, p.Encode()), &resp); err != nil { + return nil, 0, err + } + return resp.Data.AffectedItems, resp.Data.TotalAffectedItems, nil +} + +func (r *RootcheckAPI) LastScan(agentID string) (*RootcheckLastScan, error) { + var resp APIResponse[RootcheckLastScan] + if err := r.c.Get(fmt.Sprintf("/rootcheck/%s/last_scan", agentID), &resp); err != nil { + return nil, err + } + if len(resp.Data.AffectedItems) == 0 { + return nil, nil + } + return &resp.Data.AffectedItems[0], nil +} + +func (r *RootcheckAPI) Scan(agentID string) error { + var resp APIResponse[string] + if err := r.c.PutDecode("/rootcheck?agents_list="+agentID, nil, &resp); err != nil { + return err + } + if resp.Data.TotalAffectedItems == 0 && len(resp.Data.FailedItems) > 0 { + return fmt.Errorf("%s", resp.Data.FailedItems[0].Error.Message) + } + return nil +} + +func (r *RootcheckAPI) Clear(agentID string) error { + var resp APIResponse[string] + return r.c.Delete(fmt.Sprintf("/rootcheck/%s", agentID), &resp) +} diff --git a/internal/api/types.go b/internal/api/types.go index 6bf2cce..091e3be 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -328,6 +328,22 @@ type Vulnerability struct { Type string `json:"type"` } +// --- Rootcheck types --- + +type RootcheckResult struct { + Status string `json:"status"` + Event string `json:"log"` + CIS string `json:"cis"` + PCIDSS string `json:"pci_dss"` + DateFirst string `json:"date_first"` + DateLast string `json:"date_last"` +} + +type RootcheckLastScan struct { + Start string `json:"start"` + End string `json:"end"` +} + // --- Logtest types --- type LogtestRequest struct {