From a9461f6d510ac9d3600d954cf5b58877aa0c3c7d Mon Sep 17 00:00:00 2001 From: 0xbbuddha Date: Tue, 26 May 2026 10:14:12 +0200 Subject: [PATCH] feat: mitre command - browse ATT&CK techniques, tactics, groups, software --- README.md | 1 + cmd/cmd_help.go | 7 ++ cmd/cmd_mitre.go | 285 ++++++++++++++++++++++++++++++++++++++++++ cmd/dispatch.go | 1 + cmd/repl.go | 7 ++ internal/api/mitre.go | 97 ++++++++++++++ internal/api/types.go | 38 ++++++ 7 files changed, 436 insertions(+) create mode 100644 cmd/cmd_mitre.go create mode 100644 internal/api/mitre.go diff --git a/README.md b/README.md index 389b7b6..302d5f0 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Authentication is handled automatically - JWT tokens are fetched on connect and | `ar` | List and run active response actions | | `decoder` | Browse and inspect Wazuh decoders | | `syscheck` | FIM events, last scan info, trigger scan, clear results | +| `mitre` | Browse MITRE ATT&CK techniques, tactics, threat groups and software | | `logtest` | Test log lines against the rules engine | | `indices` | Manage Wazuh Indexer indices: list, delete | | `dashboard` | Live TUI: agents, alerts, vulnerabilities, SCA, FIM | diff --git a/cmd/cmd_help.go b/cmd/cmd_help.go index 2daa68e..1ae9e4c 100644 --- a/cmd/cmd_help.go +++ b/cmd/cmd_help.go @@ -111,6 +111,13 @@ var subHelp = map[string][]struct{ sub, desc string }{ {"status", "Show status of all Wazuh daemons"}, {"logs", "Show recent manager logs [--lines N]"}, }, + "mitre": { + {"techniques", "List ATT&CK techniques [--search NAME] [--tactic TACTIC] [--limit N] [--page N]"}, + {"tactics", "List all ATT&CK tactics [--search NAME]"}, + {"groups", "List threat actor groups [--search NAME] [--limit N] [--page N]"}, + {"software", "List malware and tools [--search NAME] [--limit N] [--page N]"}, + {"show ", "Show full detail for a technique (T1059, T1059.001, ...)"}, + }, "rules": { {"list", "List rules [--level N] [--group G] [--limit N]"}, {"get ", "Get details for a specific rule"}, diff --git a/cmd/cmd_mitre.go b/cmd/cmd_mitre.go new file mode 100644 index 0000000..25a6d79 --- /dev/null +++ b/cmd/cmd_mitre.go @@ -0,0 +1,285 @@ +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 handleMitre(args []string) { + if len(args) == 0 { + handleHelp([]string{"mitre"}) + return + } + switch args[0] { + case "techniques": + mitreTechniques(args[1:]) + case "tactics": + mitreTactics(args[1:]) + case "groups": + mitreGroups(args[1:]) + case "software": + mitreSoftware(args[1:]) + case "show": + mitreShow(args[1:]) + default: + printUnknownSub("mitre", args[0]) + } +} + +func mitreTechniques(args []string) { + if !needsManager() { + return + } + fs := flag.NewFlagSet("mitre techniques", flag.ContinueOnError) + search := fs.String("search", "", "Filter by name or ID") + tactic := fs.String("tactic", "", "Filter by tactic (e.g. execution, persistence)") + 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); err != nil { + return + } + output.Format = *outFmt + offset := (*page - 1) * *limit + + m := api.NewMitreAPI(managerClient) + techs, total, err := m.Techniques(*search, *tactic, *limit, offset) + if err != nil { + printErr(err) + return + } + if output.Format == "json" { + output.JSON(techs) + return + } + + totalPages := (total + *limit - 1) / *limit + if totalPages == 0 { + totalPages = 1 + } + output.ShowCount(len(techs), total, fmt.Sprintf("techniques - page %d/%d", *page, totalPages)) + + t := output.NewTable("ID", "NAME", "TACTICS", "PLATFORMS") + for _, tech := range techs { + t.Row( + color.CyanString(tech.ID), + output.Truncate(tech.Name, 40), + output.Dim(output.Truncate(strings.Join(tech.Tactics, ", "), 35)), + output.Dim(output.Truncate(strings.Join(tech.Platforms, ", "), 30)), + ) + } + 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 mitreTactics(args []string) { + if !needsManager() { + return + } + fs := flag.NewFlagSet("mitre tactics", flag.ContinueOnError) + search := fs.String("search", "", "Filter by name") + outFmt := fs.String("o", "table", "Output format: table or json") + if err := fs.Parse(args); err != nil { + return + } + output.Format = *outFmt + + m := api.NewMitreAPI(managerClient) + tactics, total, err := m.Tactics(*search, 100, 0) + if err != nil { + printErr(err) + return + } + if output.Format == "json" { + output.JSON(tactics) + return + } + + output.ShowCount(len(tactics), total, "tactics") + t := output.NewTable("ID", "NAME") + for _, tac := range tactics { + t.Row(color.CyanString(tac.ID), tac.Name) + } + t.Flush() +} + +func mitreGroups(args []string) { + if !needsManager() { + return + } + fs := flag.NewFlagSet("mitre groups", flag.ContinueOnError) + search := fs.String("search", "", "Filter by group name or alias") + 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); err != nil { + return + } + output.Format = *outFmt + offset := (*page - 1) * *limit + + m := api.NewMitreAPI(managerClient) + groups, total, err := m.Groups(*search, *limit, offset) + if err != nil { + printErr(err) + return + } + if output.Format == "json" { + output.JSON(groups) + return + } + + totalPages := (total + *limit - 1) / *limit + if totalPages == 0 { + totalPages = 1 + } + output.ShowCount(len(groups), total, fmt.Sprintf("threat groups - page %d/%d", *page, totalPages)) + + t := output.NewTable("ID", "NAME", "ALIASES", "TECHNIQUES") + for _, g := range groups { + aliases := output.Truncate(strings.Join(g.Aliases, ", "), 30) + t.Row( + color.CyanString(g.ID), + output.Truncate(g.Name, 25), + output.Dim(aliases), + output.Dim(fmt.Sprintf("%d", len(g.Techniques))), + ) + } + t.Flush() + + if *page < totalPages { + color.New(color.Faint).Printf("\n --page %d/%d\n\n", *page, totalPages) + } +} + +func mitreSoftware(args []string) { + if !needsManager() { + return + } + fs := flag.NewFlagSet("mitre software", flag.ContinueOnError) + search := fs.String("search", "", "Filter by name") + 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); err != nil { + return + } + output.Format = *outFmt + offset := (*page - 1) * *limit + + m := api.NewMitreAPI(managerClient) + software, total, err := m.Software(*search, *limit, offset) + if err != nil { + printErr(err) + return + } + if output.Format == "json" { + output.JSON(software) + return + } + + totalPages := (total + *limit - 1) / *limit + if totalPages == 0 { + totalPages = 1 + } + output.ShowCount(len(software), total, fmt.Sprintf("software - page %d/%d", *page, totalPages)) + + t := output.NewTable("ID", "NAME", "TYPE", "PLATFORMS") + for _, s := range software { + t.Row( + color.CyanString(s.ID), + output.Truncate(s.Name, 30), + output.Dim(s.Type), + output.Dim(output.Truncate(strings.Join(s.Platforms, ", "), 30)), + ) + } + t.Flush() + + if *page < totalPages { + color.New(color.Faint).Printf("\n --page %d/%d\n\n", *page, totalPages) + } +} + +func mitreShow(args []string) { + if !needsManager() { + return + } + if len(args) == 0 { + printErr(fmt.Errorf("usage: mitre show e.g. mitre show T1059")) + return + } + + m := api.NewMitreAPI(managerClient) + tech, err := m.Technique(args[0]) + if err != nil { + printErr(err) + return + } + + id := tech.ExternalID + if id == "" { + id = tech.ID + } + printSection(fmt.Sprintf("%s - %s", id, tech.Name)) + fmt.Println() + + if len(tech.Platforms) > 0 { + output.Field("Platforms", strings.Join(tech.Platforms, ", ")) + } + if len(tech.Tactics) > 0 { + output.Field("Tactics", strings.Join(tech.Tactics, ", ")) + } + + if tech.Description != "" { + fmt.Println() + printSection("Description") + fmt.Println(wordWrap(tech.Description, 80)) + } + + if len(tech.SubTechniques) > 0 { + fmt.Println() + printSection("Sub-techniques") + for _, s := range tech.SubTechniques { + fmt.Printf(" %s\n", color.CyanString(s)) + } + } + + if len(tech.Mitigations) > 0 { + fmt.Println() + printSection("Mitigations") + for _, mit := range tech.Mitigations { + fmt.Printf(" %s\n", color.GreenString(mit)) + } + } + fmt.Println() +} + +func wordWrap(text string, width int) string { + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + var sb strings.Builder + lineLen := 0 + for i, w := range words { + if i > 0 && lineLen+1+len(w) > width { + sb.WriteByte('\n') + lineLen = 0 + } else if i > 0 { + sb.WriteByte(' ') + lineLen++ + } + sb.WriteString(w) + lineLen += len(w) + } + return sb.String() +} diff --git a/cmd/dispatch.go b/cmd/dispatch.go index e8519f1..edc70ef 100644 --- a/cmd/dispatch.go +++ b/cmd/dispatch.go @@ -29,6 +29,7 @@ func init() { "indices": {"Manage Wazuh Indexer indices", handleIndex}, "logtest": {"Test a log against Wazuh rules", handleLogtest}, "manager": {"Manager information and status", handleManager}, + "mitre": {"Browse MITRE ATT&CK techniques, tactics, groups and software", handleMitre}, "status": {"Quick overview: manager, agents, indexer", handleStatus}, "rules": {"Browse detection rules", handleRules}, "sca": {"Security Configuration Assessment results", handleSCA}, diff --git a/cmd/repl.go b/cmd/repl.go index 95c61b9..136ca16 100644 --- a/cmd/repl.go +++ b/cmd/repl.go @@ -89,6 +89,13 @@ func buildCompleter() *readline.PrefixCompleter { readline.PcItem("config-edit"), ), readline.PcItem("logtest"), + readline.PcItem("mitre", + readline.PcItem("techniques"), + readline.PcItem("tactics"), + readline.PcItem("groups"), + readline.PcItem("software"), + readline.PcItem("show"), + ), readline.PcItem("manager", readline.PcItem("info"), readline.PcItem("status"), diff --git a/internal/api/mitre.go b/internal/api/mitre.go new file mode 100644 index 0000000..2c33346 --- /dev/null +++ b/internal/api/mitre.go @@ -0,0 +1,97 @@ +package api + +import ( + "fmt" + "net/url" + + "github.com/0xbbuddha/wazuh-cli/internal/client" +) + +type MitreAPI struct { + c *client.Client +} + +func NewMitreAPI(c *client.Client) *MitreAPI { + return &MitreAPI{c: c} +} + +func (m *MitreAPI) Techniques(search, tactic string, limit, offset int) ([]MITRETechnique, int, error) { + p := url.Values{} + p.Set("limit", fmt.Sprintf("%d", limit)) + if offset > 0 { + p.Set("offset", fmt.Sprintf("%d", offset)) + } + if search != "" { + p.Set("search", search) + } + if tactic != "" { + p.Set("phase_name", tactic) + } + var resp APIResponse[MITRETechnique] + if err := m.c.Get("/mitre/techniques?"+p.Encode(), &resp); err != nil { + return nil, 0, err + } + return resp.Data.AffectedItems, resp.Data.TotalAffectedItems, nil +} + +func (m *MitreAPI) Technique(id string) (*MITRETechnique, error) { + p := url.Values{} + p.Set("q", "external_id="+id) + p.Set("limit", "1") + var resp APIResponse[MITRETechnique] + if err := m.c.Get("/mitre/techniques?"+p.Encode(), &resp); err != nil { + return nil, err + } + if len(resp.Data.AffectedItems) == 0 { + return nil, fmt.Errorf("technique %q not found", id) + } + return &resp.Data.AffectedItems[0], nil +} + +func (m *MitreAPI) Tactics(search string, limit, offset int) ([]MITRETactic, int, error) { + p := url.Values{} + p.Set("limit", fmt.Sprintf("%d", limit)) + if offset > 0 { + p.Set("offset", fmt.Sprintf("%d", offset)) + } + if search != "" { + p.Set("search", search) + } + var resp APIResponse[MITRETactic] + if err := m.c.Get("/mitre/tactics?"+p.Encode(), &resp); err != nil { + return nil, 0, err + } + return resp.Data.AffectedItems, resp.Data.TotalAffectedItems, nil +} + +func (m *MitreAPI) Groups(search string, limit, offset int) ([]MITREGroup, int, error) { + p := url.Values{} + p.Set("limit", fmt.Sprintf("%d", limit)) + if offset > 0 { + p.Set("offset", fmt.Sprintf("%d", offset)) + } + if search != "" { + p.Set("search", search) + } + var resp APIResponse[MITREGroup] + if err := m.c.Get("/mitre/groups?"+p.Encode(), &resp); err != nil { + return nil, 0, err + } + return resp.Data.AffectedItems, resp.Data.TotalAffectedItems, nil +} + +func (m *MitreAPI) Software(search string, limit, offset int) ([]MITRESoftware, int, error) { + p := url.Values{} + p.Set("limit", fmt.Sprintf("%d", limit)) + if offset > 0 { + p.Set("offset", fmt.Sprintf("%d", offset)) + } + if search != "" { + p.Set("search", search) + } + var resp APIResponse[MITRESoftware] + if err := m.c.Get("/mitre/software?"+p.Encode(), &resp); err != nil { + return nil, 0, err + } + return resp.Data.AffectedItems, resp.Data.TotalAffectedItems, nil +} diff --git a/internal/api/types.go b/internal/api/types.go index dc0fb7d..6bf2cce 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -241,6 +241,44 @@ type DecoderDetail struct { Order string `json:"order"` } +// --- MITRE ATT&CK types --- + +type MITRETechnique struct { + ID string `json:"id"` + ExternalID string `json:"external_id"` + Name string `json:"name"` + Description string `json:"description"` + Platforms []string `json:"platforms"` + Tactics []string `json:"tactics"` + Mitigations []string `json:"mitigations"` + SubTechniques []string `json:"subtechniques"` +} + +type MITRETactic struct { + ID string `json:"id"` + Name string `json:"name"` + ExternalID string `json:"external_id"` +} + + +type MITREGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Aliases []string `json:"aliases"` + Techniques []string `json:"techniques"` + Software []string `json:"software"` +} + +type MITRESoftware struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Platforms []string `json:"platforms"` + Techniques []string `json:"techniques"` +} + // --- Rules types --- type Rule struct {