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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions cmd/cmd_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ var subHelp = map[string][]struct{ sub, desc string }{
{"software", "List malware and tools [--search NAME] [--limit N] [--page N]"},
{"show <id>", "Show full detail for a technique (T1059, T1059.001, ...)"},
},
"rootcheck": {
{"results <agent_id>", "List rootcheck results [--status passed|failed] [--search TEXT] [--limit N] [--page N]"},
{"last <agent_id>", "Show date of last rootcheck scan"},
{"scan <agent_id>", "Trigger a rootcheck scan on an agent"},
{"clear <agent_id>", "Wipe the rootcheck database for an agent (irreversible)"},
},
"rules": {
{"list", "List rules [--level N] [--group G] [--limit N]"},
{"get <id>", "Get details for a specific rule"},
Expand Down
171 changes: 171 additions & 0 deletions cmd/cmd_rootcheck.go
Original file line number Diff line number Diff line change
@@ -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 <agent_id> [--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 <agent_id>"))
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 <agent_id>"))
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 <agent_id>"))
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
}
}
1 change: 1 addition & 0 deletions cmd/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
6 changes: 6 additions & 0 deletions cmd/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
64 changes: 64 additions & 0 deletions internal/api/rootcheck.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading