diff --git a/cmd/crowdsec-cli/clihub/hub.go b/cmd/crowdsec-cli/clihub/hub.go index 11f7fc79b16..f3d17fc1491 100644 --- a/cmd/crowdsec-cli/clihub/hub.go +++ b/cmd/crowdsec-cli/clihub/hub.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/fatih/color" log "github.com/sirupsen/logrus" @@ -51,6 +52,7 @@ cscli hub upgrade`, cmd.AddCommand(cli.newBranchCmd()) cmd.AddCommand(cli.newListCmd()) + cmd.AddCommand(cli.newSearchCmd()) cmd.AddCommand(cli.newUpdateCmd()) cmd.AddCommand(cli.newUpgradeCmd()) cmd.AddCommand(cli.newTypesCmd()) @@ -58,7 +60,7 @@ cscli hub upgrade`, return cmd } -func (cli *cliHub) List(out io.Writer, hub *cwhub.Hub, all bool) error { +func (cli *cliHub) List(out io.Writer, hub *cwhub.Hub, all bool, full bool, statuses []string) error { cfg := cli.cfg() for _, v := range hub.Warnings { @@ -78,13 +80,39 @@ func (cli *cliHub) List(out io.Writer, hub *cwhub.Hub, all bool) error { if err != nil { return err } + + items[itemType] = filterItemsByStatus(items[itemType], statuses) } - err = ListItems(out, cfg.Cscli.Color, cwhub.ItemTypes, items, true, cfg.Cscli.Output) - if err != nil { - return err + // json/raw keep the per-type structure for scripts, regardless of the human view + if cfg.Cscli.Output != "human" { + return ListItems(out, cfg.Cscli.Color, cwhub.ItemTypes, items, true, cfg.Cscli.Output) } + // -a: flat table of every item type (installed and not) + if all { + merged := make([]*cwhub.Item, 0) + for _, itemType := range cwhub.ItemTypes { + merged = append(merged, items[itemType]...) + } + + if len(merged) == 0 { + fmt.Fprintln(out, "No items to display") + return nil + } + + listHubItemCompactTable(out, hub, cfg.Cscli.Color, merged, false) + + return nil + } + + // default: a tree of installed collections (sub-collections nested) + a group of + // standalone installed items. Status pruning happens inside the tree walk. + roots := installedRootCollections(hub) + standalone := filterItemsByStatus(installedStandalone(hub), statuses) + + listHubOverviewTable(out, hub, cfg.Cscli.Color, roots, standalone, statuses, full) + return nil } @@ -115,25 +143,43 @@ func (cli *cliHub) newBranchCmd() *cobra.Command { } func (cli *cliHub) newListCmd() *cobra.Command { - var all bool + var ( + all bool + full bool + statuses []string + ) cmd := &cobra.Command{ - Use: "list [-a]", - Short: "List all installed configurations", + Use: "list [-a]", + Short: "List relevant installed items", + Long: `List installed relevant items (collections, standalone items) and shows their status. +Use --all to list all items, including those not installed. +Use --full to expand every installed item in the tree, not just collections.`, Args: args.NoArgs, DisableAutoGenTag: true, RunE: func(_ *cobra.Command, _ []string) error { + if err := validateStatuses(statuses); err != nil { + return err + } + hub, err := require.Hub(cli.cfg(), log.StandardLogger()) if err != nil { return err } - return cli.List(color.Output, hub, all) + return cli.List(color.Output, hub, all, full, statuses) }, } flags := cmd.Flags() flags.BoolVarP(&all, "all", "a", false, "List all available items, including those not installed") + flags.BoolVar(&full, "full", false, "Show every installed item in the tree, not just collections") + flags.StringSliceVar(&statuses, "status", nil, "Filter by status ("+strings.Join(validItemStatuses, ", ")+")") + cmd.MarkFlagsMutuallyExclusive("all", "full") + + _ = cmd.RegisterFlagCompletionFunc("status", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return validItemStatuses, cobra.ShellCompDirectiveNoFileComp + }) return cmd } diff --git a/cmd/crowdsec-cli/clihub/items.go b/cmd/crowdsec-cli/clihub/items.go index bc8622894ed..1477e252088 100644 --- a/cmd/crowdsec-cli/clihub/items.go +++ b/cmd/crowdsec-cli/clihub/items.go @@ -54,6 +54,101 @@ func SelectItems(hub *cwhub.Hub, itemType string, args []string, installedOnly b return wantedItems, nil } +// validItemStatuses are the accepted values for the --status filter of hub list/search. +var validItemStatuses = []string{"installed", cwhub.StatusNotInstalled, cwhub.StatusUpToDate, cwhub.StatusOutdated, cwhub.StatusTainted, cwhub.StatusLocal} + +// validateStatuses returns an error if a token is not a recognized status filter. +func validateStatuses(statuses []string) error { + for _, s := range statuses { + if !slices.Contains(validItemStatuses, s) { + return fmt.Errorf("invalid status %q (valid values: %s)", s, strings.Join(validItemStatuses, ", ")) + } + } + + return nil +} + +// itemMatchesStatus reports whether an item's local state matches any of the given status tokens. +// An empty list matches everything. All tokens except "installed" map to a cwhub.Status* word. +func itemMatchesStatus(item *cwhub.Item, statuses []string) bool { + if len(statuses) == 0 { + return true + } + + for _, s := range statuses { + if s == "installed" { + if item.State.IsInstalled() { + return true + } + + continue + } + + if item.State.Status() == s { + return true + } + } + + return false +} + +// filterItemsByStatus returns the items whose state matches any of the given status tokens. +func filterItemsByStatus(items []*cwhub.Item, statuses []string) []*cwhub.Item { + if len(statuses) == 0 { + return items + } + + ret := make([]*cwhub.Item, 0, len(items)) + + for _, item := range items { + if itemMatchesStatus(item, statuses) { + ret = append(ret, item) + } + } + + return ret +} + +// belongsToInstalledCollection reports whether the item is pulled in by at least one installed +// collection (as opposed to being installed on its own). +func belongsToInstalledCollection(item *cwhub.Item) bool { + return len(item.InstalledParents()) > 0 +} + +// installedRootCollections returns installed collections that are not contained in any other +// installed collection. They are the roots of the "cscli hub list" tree. +func installedRootCollections(hub *cwhub.Hub) []*cwhub.Item { + ret := make([]*cwhub.Item, 0) + + for _, item := range hub.GetInstalledByType(cwhub.COLLECTIONS, true) { + if !belongsToInstalledCollection(item) { + ret = append(ret, item) + } + } + + return ret +} + +// installedStandalone returns installed non-collection items that are not part of any installed +// collection (eg. directly-installed or local items), sorted by type then name. +func installedStandalone(hub *cwhub.Hub) []*cwhub.Item { + ret := make([]*cwhub.Item, 0) + + for _, itemType := range cwhub.ItemTypes { + if itemType == cwhub.COLLECTIONS { + continue + } + + for _, item := range hub.GetInstalledByType(itemType, true) { + if !belongsToInstalledCollection(item) { + ret = append(ret, item) + } + } + } + + return ret +} + func ListItems(out io.Writer, wantColor string, itemTypes []string, items map[string][]*cwhub.Item, omitIfEmpty bool, output string) error { switch output { case "human": diff --git a/cmd/crowdsec-cli/clihub/search.go b/cmd/crowdsec-cli/clihub/search.go new file mode 100644 index 00000000000..4185fb543c1 --- /dev/null +++ b/cmd/crowdsec-cli/clihub/search.go @@ -0,0 +1,100 @@ +package clihub + +import ( + "fmt" + "io" + "strings" + + "github.com/fatih/color" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/require" + "github.com/crowdsecurity/crowdsec/pkg/cwhub" +) + +// itemMatchesTerms reports whether every term (case-insensitive) is a substring of the item name or description. +func itemMatchesTerms(item *cwhub.Item, terms []string) bool { + haystack := strings.ToLower(item.Name + " " + item.Description) + + for _, term := range terms { + if !strings.Contains(haystack, strings.ToLower(term)) { + return false + } + } + + return true +} + +func (cli *cliHub) search(out io.Writer, hub *cwhub.Hub, terms []string, statuses []string) error { + cfg := cli.cfg() + + items := make(map[string][]*cwhub.Item) + + for _, itemType := range cwhub.ItemTypes { + matched := make([]*cwhub.Item, 0) + + for _, item := range hub.GetItemsByType(itemType, true) { + if itemMatchesTerms(item, terms) && itemMatchesStatus(item, statuses) { + matched = append(matched, item) + } + } + + items[itemType] = matched + } + + // human output is a single compact table; json/raw keep the per-type structure for scripts + if cfg.Cscli.Output == "human" { + merged := make([]*cwhub.Item, 0) + for _, itemType := range cwhub.ItemTypes { + merged = append(merged, items[itemType]...) + } + + if len(merged) == 0 { + fmt.Fprintln(out, "No matching items") + return nil + } + + listHubItemCompactTable(out, hub, cfg.Cscli.Color, merged, true) + + return nil + } + + return ListItems(out, cfg.Cscli.Color, cwhub.ItemTypes, items, true, cfg.Cscli.Output) +} + +func (cli *cliHub) newSearchCmd() *cobra.Command { + var statuses []string + + cmd := &cobra.Command{ + Use: "search [term]...", + Short: "Search the local hub index by name and description", + Long: `Search the local hub index. +An item matches when its name or description contains all the given terms.`, + Example: `cscli hub search nginx +cscli hub search http cve +cscli hub search ssh --status installed`, + DisableAutoGenTag: true, + RunE: func(_ *cobra.Command, terms []string) error { + if err := validateStatuses(statuses); err != nil { + return err + } + + hub, err := require.Hub(cli.cfg(), log.StandardLogger()) + if err != nil { + return err + } + + return cli.search(color.Output, hub, terms, statuses) + }, + } + + flags := cmd.Flags() + flags.StringSliceVar(&statuses, "status", nil, "Filter by status ("+strings.Join(validItemStatuses, ", ")+")") + + _ = cmd.RegisterFlagCompletionFunc("status", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return validItemStatuses, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} diff --git a/cmd/crowdsec-cli/clihub/utils_table.go b/cmd/crowdsec-cli/clihub/utils_table.go index ff816a4a7c9..53f9b6ca41b 100644 --- a/cmd/crowdsec-cli/clihub/utils_table.go +++ b/cmd/crowdsec-cli/clihub/utils_table.go @@ -3,14 +3,43 @@ package clihub import ( "fmt" "io" + "strings" "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/cstable" "github.com/crowdsecurity/crowdsec/pkg/cwhub" "github.com/crowdsecurity/crowdsec/pkg/emoji" ) +// dimColor is the faint style used for secondary info (tree markers, type, details). +var dimColor = text.Colors{text.FgHiBlack} + +// 256-color codes for shades not in the basic 16-color ANSI set. +const ( + color256Limeade = 154 // yellow-green + color256Orange = 208 +) + +// statusColor maps a status word to its palette, tracking the status emoji. +func statusColor(status string) text.Colors { + switch status { + case cwhub.StatusUpToDate: + return text.Colors{text.FgGreen} + case cwhub.StatusOutdated: + return text.Colors{text.Fg256Color(color256Limeade)} + case cwhub.StatusTainted: + return text.Colors{text.Fg256Color(color256Orange), text.Bold} + case cwhub.StatusLocal: + return text.Colors{text.FgCyan} + case cwhub.StatusNotInstalled: + return text.Colors{text.FgHiBlack} + default: + return nil + } +} + func listHubItemTable(out io.Writer, wantColor string, title string, items []*cwhub.Item) { t := cstable.NewLight(out, wantColor).Writer t.AppendHeader(table.Row{"Name", fmt.Sprintf("%v Status", emoji.Package), "Version", "Local Path"}) @@ -23,3 +52,240 @@ func listHubItemTable(out io.Writer, wantColor string, title string, items []*cw t.SetTitle(title) fmt.Fprintln(out, t.Render()) } + +// collectionSummary returns a short count of a collection's contents, eg. "2 parser(s) / 3 scenario(s)". +func collectionSummary(item *cwhub.Item) string { + groups := item.ByType() + + parts := make([]string, 0, len(groups)) + + for _, g := range groups { + if len(g.Names) > 0 { + label := strings.TrimSuffix(g.Type, "s") + "(s)" + parts = append(parts, fmt.Sprintf("%d %s", len(g.Names), label)) + } + } + + return strings.Join(parts, " / ") +} + +// itemDetails returns the rightmost column: for collections a content summary (with the version +// delta prepended when outdated), for leaf items a short status reason. +func itemDetails(item *cwhub.Item) string { + if item.Type == cwhub.COLLECTIONS { + summary := collectionSummary(item) + if item.State.Status() != cwhub.StatusOutdated { + return summary + } + + delta := fmt.Sprintf("%s → %s", item.State.LocalVersion, item.Version) + if summary == "" { + return delta + } + + return delta + " · " + summary + } + + switch item.State.Status() { + case cwhub.StatusTainted: + // only collections inherit taint, so a tainted leaf was edited directly + return "edited locally" + case cwhub.StatusOutdated: + return fmt.Sprintf("%s → %s", item.State.LocalVersion, item.Version) + default: + return "" + } +} + +func hubTableHeader(showDesc bool) table.Row { + header := table.Row{"Type", "Name", fmt.Sprintf("%v Status", emoji.Package), "Version", "Details"} + if showDesc { + header = append(header, "Description") + } + + return header +} + +func appendItemRow(t table.Writer, item *cwhub.Item, namePrefix string, showDesc, colorize bool) { + statusWord := item.State.Status() + name := item.Name + itemType := item.Type + prefix := namePrefix + details := itemDetails(item) + + if colorize { + statusWord = statusColor(statusWord).Sprint(statusWord) + if item.Type == cwhub.COLLECTIONS { + name = text.Bold.Sprint(name) + } + + if prefix != "" { + prefix = dimColor.Sprint(prefix) + } + + itemType = dimColor.Sprint(itemType) + + if details != "" { + details = dimColor.Sprint(details) + } + } + + status := fmt.Sprintf("%v %s", item.State.Emoji(), statusWord) + row := table.Row{itemType, prefix + name, status, item.State.LocalVersion, details} + + if showDesc { + row = append(row, strings.TrimSpace(item.Description)) + } + + t.AppendRow(row) +} + +// appendItemTree appends an item row and, for a tainted collection, its tainted sub-items +// (from State.TaintedBy) as indented child rows. +func appendItemTree(t table.Writer, hub *cwhub.Hub, item *cwhub.Item, showDesc, colorize bool) { + appendItemRow(t, item, "", showDesc, colorize) + + if item.Type != cwhub.COLLECTIONS || item.State.Status() != cwhub.StatusTainted { + return + } + + for _, fq := range item.State.TaintedBy { + if fq == item.FQName() { + continue + } + + sub, err := hub.GetItemFQ(fq) + if err != nil || sub == nil { + continue + } + + appendItemRow(t, sub, " └─ ", showDesc, colorize) + } +} + +// taintChildFQNames returns the set of sub-items that will be shown indented under a tainted +// collection, so they are not also rendered as top-level rows. +func taintChildFQNames(items []*cwhub.Item) map[string]bool { + asChild := make(map[string]bool) + + for _, item := range items { + if item.Type == cwhub.COLLECTIONS && item.State.Status() == cwhub.StatusTainted { + for _, fq := range item.State.TaintedBy { + if fq != item.FQName() { + asChild[fq] = true + } + } + } + } + + return asChild +} + +// listHubItemCompactTable renders a single flat table across all item types. +// A tainted collection expands its culprit sub-items as indented child rows. +func listHubItemCompactTable(out io.Writer, hub *cwhub.Hub, wantColor string, items []*cwhub.Item, showDesc bool) { + colorize := cstable.ShouldColorize(wantColor) + + t := cstable.NewLight(out, wantColor).Writer + t.AppendHeader(hubTableHeader(showDesc)) + + asChild := taintChildFQNames(items) + + for _, item := range items { + if asChild[item.FQName()] { + continue + } + + appendItemTree(t, hub, item, showDesc, colorize) + } + + fmt.Fprintln(out, t.Render()) +} + +func treePrefix(depth int) string { + if depth == 0 { + return "" + } + + return strings.Repeat(" ", depth) + "└─ " +} + +type overviewRow struct { + item *cwhub.Item + depth int +} + +// collectionRows returns the rows for a collection subtree: the collection itself, its installed +// sub-collections (recursively), and its direct leaf sub-items. By default only tainted leaves get +// a row (others are counted in the Details column); with full, every installed leaf is shown. +func collectionRows(hub *cwhub.Hub, item *cwhub.Item, depth int, statuses []string, seen map[string]bool, full bool) []overviewRow { + if seen[item.FQName()] { + return nil + } + + seen[item.FQName()] = true + + var children []overviewRow + + for sub := range item.CurrentDependencies().SubItems(hub) { + if !sub.State.IsInstalled() { + continue + } + + if sub.Type == cwhub.COLLECTIONS { + children = append(children, collectionRows(hub, sub, depth+1, statuses, seen, full)...) + continue + } + + // a leaf shared by several installed collections is shown once, under the first + if seen[sub.FQName()] { + continue + } + + if (full || sub.State.Status() == cwhub.StatusTainted) && itemMatchesStatus(sub, statuses) { + seen[sub.FQName()] = true + children = append(children, overviewRow{sub, depth + 1}) + } + } + + if len(children) == 0 && !itemMatchesStatus(item, statuses) { + return nil + } + + return append([]overviewRow{{item, depth}}, children...) +} + +// listHubOverviewTable renders the default "cscli hub list" view: a tree of relevant installed items. +// With full, every installed leaf sub-item is shown instead of only the tainted ones. +func listHubOverviewTable(out io.Writer, hub *cwhub.Hub, wantColor string, roots, standalone []*cwhub.Item, statuses []string, full bool) { + seen := make(map[string]bool) + + var rows []overviewRow + for _, root := range roots { + rows = append(rows, collectionRows(hub, root, 0, statuses, seen, full)...) + } + + if len(rows) == 0 && len(standalone) == 0 { + fmt.Fprintln(out, "No items to display") + return + } + + colorize := cstable.ShouldColorize(wantColor) + + t := cstable.NewLight(out, wantColor).Writer + t.AppendHeader(hubTableHeader(false)) + + for _, row := range rows { + appendItemRow(t, row.item, treePrefix(row.depth), false, colorize) + } + + if len(rows) > 0 && len(standalone) > 0 { + t.AppendSeparator() + } + + for _, item := range standalone { + appendItemRow(t, item, "", false, colorize) + } + + fmt.Fprintln(out, t.Render()) +} diff --git a/cmd/crowdsec-cli/cliitem/cmdremove.go b/cmd/crowdsec-cli/cliitem/cmdremove.go index 79f7a9e1001..b77b91d3c3a 100644 --- a/cmd/crowdsec-cli/cliitem/cmdremove.go +++ b/cmd/crowdsec-cli/cliitem/cmdremove.go @@ -77,10 +77,8 @@ func (cli *cliItem) removePlan(hub *cwhub.Hub, args []string, purge bool, force func installedParentNames(item *cwhub.Item) []string { ret := make([]string, 0) - for _, parent := range item.Ancestors() { - if parent.State.IsInstalled() { - ret = append(ret, parent.Name) - } + for _, parent := range item.InstalledParents() { + ret = append(ret, parent.Name) } return ret diff --git a/cmd/crowdsec-cli/clisupport/support.go b/cmd/crowdsec-cli/clisupport/support.go index 733650efb20..515474a8b85 100644 --- a/cmd/crowdsec-cli/clisupport/support.go +++ b/cmd/crowdsec-cli/clisupport/support.go @@ -226,9 +226,20 @@ func (cli *cliSupport) dumpHubItems(zw *zip.Writer, hub *cwhub.Hub) error { } out := new(bytes.Buffer) - ch := clihub.New(cli.cfg) - if err := ch.List(out, hub, false); err != nil { + // dump every installed item for diagnostics, not the collection-centric "cscli hub list" view + items := make(map[string][]*cwhub.Item) + + for _, itemType := range cwhub.ItemTypes { + selected, err := clihub.SelectItems(hub, itemType, nil, true) + if err != nil { + return err + } + + items[itemType] = selected + } + + if err := clihub.ListItems(out, "no", cwhub.ItemTypes, items, true, "human"); err != nil { return err } diff --git a/cmd/crowdsec-cli/core/cstable/cstable.go b/cmd/crowdsec-cli/core/cstable/cstable.go index 85ba491f4e8..1ffaa4a3ef4 100644 --- a/cmd/crowdsec-cli/core/cstable/cstable.go +++ b/cmd/crowdsec-cli/core/cstable/cstable.go @@ -13,7 +13,8 @@ import ( isatty "github.com/mattn/go-isatty" ) -func shouldWeColorize(wantColor string) bool { +// ShouldColorize reports whether output should be colorized for the given --color value. +func ShouldColorize(wantColor string) bool { switch wantColor { case "yes": return true @@ -39,7 +40,7 @@ func New(out io.Writer, wantColor string) *Table { t := table.NewWriter() // colorize output, use unicode box characters - fancy := shouldWeColorize(wantColor) + fancy := ShouldColorize(wantColor) colorOptions := table.ColorOptions{} diff --git a/pkg/cwhub/item.go b/pkg/cwhub/item.go index f579e417e2d..28922108af3 100644 --- a/pkg/cwhub/item.go +++ b/pkg/cwhub/item.go @@ -54,14 +54,16 @@ type Dependencies struct { AppsecRules []string `json:"appsec-rules,omitempty" yaml:"appsec-rules,omitempty"` } -// a group of items of the same type -type itemgroup struct { - typeName string - itemNames []string +// DependencyGroup pairs an item type with the names of the dependencies of that type. +type DependencyGroup struct { + Type string + Names []string } -func (d Dependencies) byType() []itemgroup { - return []itemgroup{ +// ByType returns the direct dependencies grouped by item type, in ItemTypes order. +// This is the single source of truth for iterating a Dependencies struct by type. +func (d Dependencies) ByType() []DependencyGroup { + return []DependencyGroup{ {PARSERS, d.Parsers}, {POSTOVERFLOWS, d.PostOverflows}, {SCENARIOS, d.Scenarios}, @@ -75,9 +77,9 @@ func (d Dependencies) byType() []itemgroup { // SubItems iterates over the sub-items in the struct, excluding the ones that were not found in the hub. func (d Dependencies) SubItems(hub *Hub) func(func(*Item) bool) { return func(yield func(*Item) bool) { - for _, typeGroup := range d.byType() { - for _, name := range typeGroup.itemNames { - s := hub.GetItem(typeGroup.typeName, name) + for _, group := range d.ByType() { + for _, name := range group.Names { + s := hub.GetItem(group.Type, name) if s == nil { continue } @@ -239,10 +241,10 @@ func (i *Item) CurrentDependencies() Dependencies { } func (i *Item) logMissingSubItems() { - for _, sub := range i.CurrentDependencies().byType() { - for _, subName := range sub.itemNames { - if i.hub.GetItem(sub.typeName, subName) == nil { - i.hub.logger.Errorf("can't find %s:%s, required by %s", sub.typeName, subName, i.Name) + for _, group := range i.CurrentDependencies().ByType() { + for _, subName := range group.Names { + if i.hub.GetItem(group.Type, subName) == nil { + i.hub.logger.Errorf("can't find %s:%s, required by %s", group.Type, subName, i.Name) } } } @@ -264,6 +266,20 @@ func (i *Item) Ancestors() []*Item { return ret } +// InstalledParents returns the installed collections that have this item as a direct or indirect +// dependency. +func (i *Item) InstalledParents() []*Item { + ret := make([]*Item, 0) + + for _, parent := range i.Ancestors() { + if parent.State.IsInstalled() { + ret = append(ret, parent) + } + } + + return ret +} + // SafeToRemoveDeps returns a slice of dependencies that can be safely removed when this item is removed. // The returned slice can contain items that are not installed, or not downloaded. func (i *Item) SafeToRemoveDeps() ([]*Item, error) { @@ -393,7 +409,12 @@ func (i *Item) FQName() string { // addTaint marks the item as tainted, and propagates the taint to the ancestors. // sub: the sub-item that caused the taint. May be the item itself! +// Taint only applies to installed items. func (i *Item) addTaint(sub *Item) { + if !i.State.IsInstalled() { + return + } + i.State.Tainted = true taintedBy := sub.FQName() diff --git a/pkg/cwhub/state.go b/pkg/cwhub/state.go index 3e1876712b3..0c844042c57 100644 --- a/pkg/cwhub/state.go +++ b/pkg/cwhub/state.go @@ -4,6 +4,16 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/emoji" ) +// Single-word item statuses returned by ItemState.Status(). Unlike Text(), which composes a +// compound status (eg. "enabled,tainted"), these are mutually exclusive. +const ( + StatusNotInstalled = "not-installed" + StatusTainted = "tainted" + StatusLocal = "local" + StatusOutdated = "outdated" + StatusUpToDate = "up-to-date" +) + // ItemState is used to keep the local state (i.e. at runtime) of an item. // This data is not stored in the index, but is displayed with "cscli ... inspect". type ItemState struct { @@ -45,6 +55,22 @@ func (s *ItemState) Text() string { return ret } +// Status returns the item's state as a single mutually-exclusive word +func (s *ItemState) Status() string { + switch { + case !s.IsInstalled(): + return StatusNotInstalled + case s.Tainted: + return StatusTainted + case s.IsLocal(): + return StatusLocal + case !s.UpToDate: + return StatusOutdated + default: + return StatusUpToDate + } +} + // Emoji returns the status of the item as an emoji (eg. emoji.Warning). func (s *ItemState) Emoji() string { switch { diff --git a/test/bats/20_hub.bats b/test/bats/20_hub.bats index 9e4ba3ce155..1824dc2da12 100644 --- a/test/bats/20_hub.bats +++ b/test/bats/20_hub.bats @@ -44,33 +44,139 @@ teardown() { rune -0 cscli hub list -o raw assert_output 'name,status,version,description,type' - # some items: with output=human, show only non-empty tables + # default view is collection-centric: an installed collection shows a content summary, + # and its (clean) sub-items are not listed individually + rune -0 cscli collections install crowdsecurity/sshd + rune -0 cscli hub list + assert_output --regexp "collections.*crowdsecurity/sshd.*parser\(s\)" + refute_output --partial 'crowdsecurity/ssh-bf' + + # an item that belongs to no installed collection shows in the standalone group rune -0 cscli parsers install crowdsecurity/whitelists - rune -0 cscli scenarios install crowdsecurity/telnet-bf rune -0 cscli hub list - assert_output --regexp ".*PARSERS.*crowdsecurity/whitelists.*SCENARIOS.*crowdsecurity/telnet-bf.*" - refute_output --partial 'POSTOVERFLOWS' - refute_output --partial 'COLLECTIONS' + assert_output --partial 'crowdsecurity/sshd' + assert_output --partial 'crowdsecurity/whitelists' + # -a is a flat list of every item type, including those not installed + rune -0 cscli hub list -a + assert_output --partial 'crowdsecurity/sshd' + assert_output --partial 'crowdsecurity/whitelists' + assert_output --partial 'crowdsecurity/ssh-bf' + assert_output --partial 'crowdsecurity/iptables' + + # json/raw keep the full installed per-type structure for scripts rune -0 cscli hub list -o json - rune -0 jq -e '(.parsers | length == 1) and (.scenarios | length == 1)' <(output) + rune -0 jq -e '(.collections | length == 1) and (.parsers | length >= 1)' <(output) rune -0 cscli hub list -o raw + assert_output --partial 'crowdsecurity/sshd' assert_output --partial 'crowdsecurity/whitelists' - assert_output --partial 'crowdsecurity/telnet-bf' refute_output --partial 'crowdsecurity/iptables' +} - # all items - mkdir -p "$CONFIG_DIR/contexts" - # there are no contexts yet, so we create a local one - touch "$CONFIG_DIR/contexts/mycontext.yaml" - rune -0 cscli hub list -a - assert_output --regexp ".*PARSERS.*crowdsecurity/whitelists.*POSTOVERFLOWS.*SCENARIOS.*crowdsecurity/telnet-bf.*CONTEXTS.*mycontext.yaml.*COLLECTIONS.*crowdsecurity/iptables.*" - rune -0 cscli hub list -a -o json - rune -0 jq -e '(.parsers | length > 1) and (.scenarios | length > 1)' <(output) - rune -0 cscli hub list -a -o raw +@test "taint does not propagate to non-installed parent collections" { + hub_purge_all + # sshd-logs is a dependency of several collections we do NOT install (linux, freebsd, ...) + rune -0 cscli parsers install crowdsecurity/sshd-logs + rune -0 truncate -s0 "$CONFIG_DIR/parsers/s01-parse/sshd-logs.yaml" + + # the installed parser is tainted + rune -0 cscli parsers inspect crowdsecurity/sshd-logs -o json --no-metrics + rune -0 jq -e '.tainted == true' <(output) + + # a collection that merely lists it, but is not installed, must not be marked tainted + rune -0 cscli collections inspect crowdsecurity/linux -o json --no-metrics + rune -0 jq -e '(.installed == false) and (.tainted == false)' <(output) +} + +@test "cscli hub list (sub-collections are nested under their parent)" { + hub_purge_all + rune -0 cscli collections install crowdsecurity/nginx + + rune -0 cscli hub list + # nginx is a root collection; base-http-scenarios is one of its dependencies + assert_output --regexp "collections.*crowdsecurity/nginx" + # the sub-collection is shown indented beneath it, not as its own root row + assert_output --regexp "└─.*crowdsecurity/base-http-scenarios" +} + +@test "cscli hub list --full expands every installed leaf into the tree" { + hub_purge_all + rune -0 cscli collections install crowdsecurity/sshd + + # default view summarizes clean leaves: ssh-bf is not shown as its own row + rune -0 cscli hub list + refute_output --partial 'crowdsecurity/ssh-bf' + + # --full shows every installed leaf, indented under its collection + rune -0 cscli hub list --full + assert_output --regexp "└─.*crowdsecurity/ssh-bf" + + # --full and -a are mutually exclusive + rune -1 cscli hub list -a --full + assert_stderr --partial 'none of the others can be' +} + +@test "cscli hub list (tainted collection shows tainted sub-items as a tree)" { + hub_purge_all + rune -0 cscli collections install crowdsecurity/sshd + + # taint one scenario that belongs to the collection + rune -0 truncate -s0 "$CONFIG_DIR/scenarios/ssh-bf.yaml" + + rune -0 cscli hub list --status tainted + # the collection is reported tainted... + assert_output --regexp "collections.*crowdsecurity/sshd.*tainted" + # ...with the tainted sub-item indented beneath it + assert_output --regexp "crowdsecurity/ssh-bf.*edited locally" + # a clean sibling is not listed as its own row + refute_output --partial 'crowdsecurity/ssh-slow-bf' +} + +@test "cscli hub list --status" { + hub_purge_all + rune -0 cscli parsers install crowdsecurity/whitelists + + rune -0 cscli hub list --status up-to-date assert_output --partial 'crowdsecurity/whitelists' - assert_output --partial 'crowdsecurity/telnet-bf' + + # a freshly installed item is not outdated + rune -0 cscli hub list --status outdated + assert_output 'No items to display' + + # not-installed candidates only show up with -a + rune -0 cscli hub list -a --status not-installed assert_output --partial 'crowdsecurity/iptables' + refute_output --partial 'crowdsecurity/whitelists' + + rune -1 cscli hub list --status bogus + assert_stderr --partial 'invalid status "bogus"' +} + +@test "cscli hub search" { + hub_purge_all + + # search the local index by name, no install required + rune -0 cscli hub search sshd + assert_output --partial 'crowdsecurity/sshd' + + # multiple terms are ANDed together + rune -0 cscli hub search http cve + assert_output --partial 'http-cve' + + # no match: friendly message, exit 0 + rune -0 cscli hub search zzzznotanitem + assert_output 'No matching items' + + # json output keeps the per-type structure + rune -0 cscli hub search sshd -o json + rune -0 jq -e '[.[][].name] | any(. == "crowdsecurity/sshd")' <(output) + + # status filter applies to search results + rune -0 cscli collections install crowdsecurity/sshd + rune -0 cscli hub search sshd --status installed + assert_output --partial 'crowdsecurity/sshd' + rune -0 cscli hub search sshd --status tainted + assert_output 'No matching items' } @test "cscli hub list (invalid index)" {