diff --git a/cmd/crowdsec-cli/hub.go b/cmd/crowdsec-cli/hub.go index 4d7518143..d8895ef2c 100644 --- a/cmd/crowdsec-cli/hub.go +++ b/cmd/crowdsec-cli/hub.go @@ -149,6 +149,7 @@ func (cli cliHub) upgrade(cmd *cobra.Command, args []string) error { updated := 0 log.Infof("Upgrading %s", itemType) + for _, item := range items { didUpdate, err := item.Upgrade(force) if err != nil { @@ -158,6 +159,7 @@ func (cli cliHub) upgrade(cmd *cobra.Command, args []string) error { updated++ } } + log.Infof("Upgraded %d %s", updated, itemType) } diff --git a/cmd/crowdsec-cli/hubappsec.go b/cmd/crowdsec-cli/hubappsec.go index 7c68d6f13..364519dd4 100644 --- a/cmd/crowdsec-cli/hubappsec.go +++ b/cmd/crowdsec-cli/hubappsec.go @@ -49,16 +49,19 @@ cscli appsec-configs list crowdsecurity/vpatch`, func NewCLIAppsecRule() *cliItem { inspectDetail := func(item *cwhub.Item) error { appsecRule := appsec.AppsecCollectionConfig{} + yamlContent, err := os.ReadFile(item.State.LocalPath) if err != nil { return fmt.Errorf("unable to read file %s : %s", item.State.LocalPath, err) } + if err := yaml.Unmarshal(yamlContent, &appsecRule); err != nil { return fmt.Errorf("unable to unmarshal yaml file %s : %s", item.State.LocalPath, err) } for _, ruleType := range appsec_rule.SupportedTypes() { fmt.Printf("\n%s format:\n", cases.Title(language.Und, cases.NoLower).String(ruleType)) + for _, rule := range appsecRule.Rules { convertedRule, _, err := rule.Convert(ruleType, appsecRule.Name) if err != nil { diff --git a/cmd/crowdsec-cli/hubtest.go b/cmd/crowdsec-cli/hubtest.go index 532a76f22..daf22fb5c 100644 --- a/cmd/crowdsec-cli/hubtest.go +++ b/cmd/crowdsec-cli/hubtest.go @@ -41,7 +41,7 @@ func (cli cliHubTest) NewCommand() *cobra.Command { Long: "Run functional tests on hub configurations (parsers, scenarios, collections...)", Args: cobra.ExactArgs(0), DisableAutoGenTag: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { var err error HubTest, err = hubtest.NewHubTest(hubPath, crowdsecPath, cscliPath, false) if err != nil { @@ -94,7 +94,7 @@ cscli hubtest create my-nginx-custom-test --type nginx cscli hubtest create my-scenario-test --parsers crowdsecurity/nginx --scenarios crowdsecurity/http-probing`, Args: cobra.ExactArgs(1), DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { testName := args[0] testPath := filepath.Join(hubPtr.HubTestPath, testName) if _, err := os.Stat(testPath); os.IsExist(err) { @@ -262,7 +262,7 @@ func (cli cliHubTest) NewRunCmd() *cobra.Command { return nil }, - PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + PersistentPostRunE: func(_ *cobra.Command, _ []string) error { success := true testResult := make(map[string]bool) for _, test := range hubPtr.Tests { @@ -388,7 +388,7 @@ func (cli cliHubTest) NewCleanCmd() *cobra.Command { Short: "clean [test_name]", Args: cobra.MinimumNArgs(1), DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { for _, testName := range args { test, err := hubPtr.LoadTestItem(testName) if err != nil { @@ -412,7 +412,7 @@ func (cli cliHubTest) NewInfoCmd() *cobra.Command { Short: "info [test_name]", Args: cobra.MinimumNArgs(1), DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { for _, testName := range args { test, err := hubPtr.LoadTestItem(testName) if err != nil { @@ -444,7 +444,7 @@ func (cli cliHubTest) NewListCmd() *cobra.Command { Use: "list", Short: "list", DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { if err := hubPtr.LoadAllTests(); err != nil { return fmt.Errorf("unable to load all tests: %s", err) } @@ -479,7 +479,7 @@ func (cli cliHubTest) NewCoverageCmd() *cobra.Command { Use: "coverage", Short: "coverage", DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { //for this one we explicitly don't do for appsec if err := HubTest.LoadAllTests(); err != nil { return fmt.Errorf("unable to load all tests: %+v", err) @@ -617,7 +617,7 @@ func (cli cliHubTest) NewEvalCmd() *cobra.Command { Short: "eval [test_name]", Args: cobra.ExactArgs(1), DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { for _, testName := range args { test, err := hubPtr.LoadTestItem(testName) if err != nil { @@ -652,7 +652,7 @@ func (cli cliHubTest) NewExplainCmd() *cobra.Command { Short: "explain [test_name]", Args: cobra.ExactArgs(1), DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { for _, testName := range args { test, err := HubTest.LoadTestItem(testName) if err != nil { diff --git a/cmd/crowdsec-cli/item_metrics.go b/cmd/crowdsec-cli/item_metrics.go index 34484f63d..b99992613 100644 --- a/cmd/crowdsec-cli/item_metrics.go +++ b/cmd/crowdsec-cli/item_metrics.go @@ -34,8 +34,7 @@ func ShowMetrics(hubItem *cwhub.Item) error { } case cwhub.APPSEC_RULES: log.Error("FIXME: not implemented yet") - default: - // no metrics for this item type + default: // no metrics for this item type } return nil } @@ -222,6 +221,7 @@ var ranges = []unit{ func formatNumber(num int) string { goodUnit := unit{} + for _, u := range ranges { if int64(num) >= u.value { goodUnit = u @@ -234,5 +234,6 @@ func formatNumber(num int) string { } res := math.Round(float64(num)/float64(goodUnit.value)*100) / 100 + return fmt.Sprintf("%.2f%s", res, goodUnit.symbol) } diff --git a/cmd/crowdsec-cli/itemcli.go b/cmd/crowdsec-cli/itemcli.go index 6dfdb5d1f..112fc017e 100644 --- a/cmd/crowdsec-cli/itemcli.go +++ b/cmd/crowdsec-cli/itemcli.go @@ -2,8 +2,13 @@ package main import ( "fmt" + "os" + "strings" "github.com/fatih/color" + "github.com/hexops/gotextdiff" + "github.com/hexops/gotextdiff/myers" + "github.com/hexops/gotextdiff/span" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -35,27 +40,27 @@ type cliItem struct { listHelp cliHelp } -func (it cliItem) NewCommand() *cobra.Command { +func (cli cliItem) NewCommand() *cobra.Command { cmd := &cobra.Command{ - Use: coalesce.String(it.help.use, fmt.Sprintf("%s [item]...", it.name)), - Short: coalesce.String(it.help.short, fmt.Sprintf("Manage hub %s", it.name)), - Long: it.help.long, - Example: it.help.example, + Use: coalesce.String(cli.help.use, fmt.Sprintf("%s [item]...", cli.name)), + Short: coalesce.String(cli.help.short, fmt.Sprintf("Manage hub %s", cli.name)), + Long: cli.help.long, + Example: cli.help.example, Args: cobra.MinimumNArgs(1), - Aliases: []string{it.singular}, + Aliases: []string{cli.singular}, DisableAutoGenTag: true, } - cmd.AddCommand(it.NewInstallCmd()) - cmd.AddCommand(it.NewRemoveCmd()) - cmd.AddCommand(it.NewUpgradeCmd()) - cmd.AddCommand(it.NewInspectCmd()) - cmd.AddCommand(it.NewListCmd()) + cmd.AddCommand(cli.NewInstallCmd()) + cmd.AddCommand(cli.NewRemoveCmd()) + cmd.AddCommand(cli.NewUpgradeCmd()) + cmd.AddCommand(cli.NewInspectCmd()) + cmd.AddCommand(cli.NewListCmd()) return cmd } -func (it cliItem) Install(cmd *cobra.Command, args []string) error { +func (cli cliItem) Install(cmd *cobra.Command, args []string) error { flags := cmd.Flags() downloadOnly, err := flags.GetBool("download-only") @@ -79,9 +84,9 @@ func (it cliItem) Install(cmd *cobra.Command, args []string) error { } for _, name := range args { - item := hub.GetItem(it.name, name) + item := hub.GetItem(cli.name, name) if item == nil { - msg := suggestNearestMessage(hub, it.name, name) + msg := suggestNearestMessage(hub, cli.name, name) if !ignoreError { return fmt.Errorf(msg) } @@ -103,24 +108,24 @@ func (it cliItem) Install(cmd *cobra.Command, args []string) error { return nil } -func (it cliItem) NewInstallCmd() *cobra.Command { +func (cli cliItem) NewInstallCmd() *cobra.Command { cmd := &cobra.Command{ - Use: coalesce.String(it.installHelp.use, "install [item]..."), - Short: coalesce.String(it.installHelp.short, fmt.Sprintf("Install given %s", it.oneOrMore)), - Long: coalesce.String(it.installHelp.long, fmt.Sprintf("Fetch and install one or more %s from the hub", it.name)), - Example: it.installHelp.example, + Use: coalesce.String(cli.installHelp.use, "install [item]..."), + Short: coalesce.String(cli.installHelp.short, fmt.Sprintf("Install given %s", cli.oneOrMore)), + Long: coalesce.String(cli.installHelp.long, fmt.Sprintf("Fetch and install one or more %s from the hub", cli.name)), + Example: cli.installHelp.example, Args: cobra.MinimumNArgs(1), DisableAutoGenTag: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return compAllItems(it.name, args, toComplete) + return compAllItems(cli.name, args, toComplete) }, - RunE: it.Install, + RunE: cli.Install, } flags := cmd.Flags() flags.BoolP("download-only", "d", false, "Only download packages, don't enable") flags.Bool("force", false, "Force install: overwrite tainted and outdated files") - flags.Bool("ignore", false, fmt.Sprintf("Ignore errors when installing multiple %s", it.name)) + flags.Bool("ignore", false, fmt.Sprintf("Ignore errors when installing multiple %s", cli.name)) return cmd } @@ -138,7 +143,7 @@ func istalledParentNames(item *cwhub.Item) []string { return ret } -func (it cliItem) Remove(cmd *cobra.Command, args []string) error { +func (cli cliItem) Remove(cmd *cobra.Command, args []string) error { flags := cmd.Flags() purge, err := flags.GetBool("purge") @@ -167,7 +172,7 @@ func (it cliItem) Remove(cmd *cobra.Command, args []string) error { getter = hub.GetAllItems } - items, err := getter(it.name) + items, err := getter(cli.name) if err != nil { return err } @@ -185,7 +190,7 @@ func (it cliItem) Remove(cmd *cobra.Command, args []string) error { } } - log.Infof("Removed %d %s", removed, it.name) + log.Infof("Removed %d %s", removed, cli.name) if removed > 0 { log.Infof(ReloadMessage()) } @@ -194,22 +199,23 @@ func (it cliItem) Remove(cmd *cobra.Command, args []string) error { } if len(args) == 0 { - return fmt.Errorf("specify at least one %s to remove or '--all'", it.singular) + return fmt.Errorf("specify at least one %s to remove or '--all'", cli.singular) } removed := 0 for _, itemName := range args { - item := hub.GetItem(it.name, itemName) + item := hub.GetItem(cli.name, itemName) if item == nil { - return fmt.Errorf("can't find '%s' in %s", itemName, it.name) + return fmt.Errorf("can't find '%s' in %s", itemName, cli.name) } parents := istalledParentNames(item) if !force && len(parents) > 0 { log.Warningf("%s belongs to collections: %s", item.Name, parents) - log.Warningf("Run 'sudo cscli %s remove %s --force' if you want to force remove this %s", item.Type, item.Name, it.singular) + log.Warningf("Run 'sudo cscli %s remove %s --force' if you want to force remove this %s", item.Type, item.Name, cli.singular) + continue } @@ -224,7 +230,7 @@ func (it cliItem) Remove(cmd *cobra.Command, args []string) error { } } - log.Infof("Removed %d %s", removed, it.name) + log.Infof("Removed %d %s", removed, cli.name) if removed > 0 { log.Infof(ReloadMessage()) } @@ -232,29 +238,29 @@ func (it cliItem) Remove(cmd *cobra.Command, args []string) error { return nil } -func (it cliItem) NewRemoveCmd() *cobra.Command { +func (cli cliItem) NewRemoveCmd() *cobra.Command { cmd := &cobra.Command{ - Use: coalesce.String(it.removeHelp.use, "remove [item]..."), - Short: coalesce.String(it.removeHelp.short, fmt.Sprintf("Remove given %s", it.oneOrMore)), - Long: coalesce.String(it.removeHelp.long, fmt.Sprintf("Remove one or more %s", it.name)), - Example: it.removeHelp.example, + Use: coalesce.String(cli.removeHelp.use, "remove [item]..."), + Short: coalesce.String(cli.removeHelp.short, fmt.Sprintf("Remove given %s", cli.oneOrMore)), + Long: coalesce.String(cli.removeHelp.long, fmt.Sprintf("Remove one or more %s", cli.name)), + Example: cli.removeHelp.example, Aliases: []string{"delete"}, DisableAutoGenTag: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return compInstalledItems(it.name, args, toComplete) + return compInstalledItems(cli.name, args, toComplete) }, - RunE: it.Remove, + RunE: cli.Remove, } flags := cmd.Flags() flags.Bool("purge", false, "Delete source file too") flags.Bool("force", false, "Force remove: remove tainted and outdated files") - flags.Bool("all", false, fmt.Sprintf("Remove all the %s", it.name)) + flags.Bool("all", false, fmt.Sprintf("Remove all the %s", cli.name)) return cmd } -func (it cliItem) Upgrade(cmd *cobra.Command, args []string) error { +func (cli cliItem) Upgrade(cmd *cobra.Command, args []string) error { flags := cmd.Flags() force, err := flags.GetBool("force") @@ -273,7 +279,7 @@ func (it cliItem) Upgrade(cmd *cobra.Command, args []string) error { } if all { - items, err := hub.GetInstalledItems(it.name) + items, err := hub.GetInstalledItems(cli.name) if err != nil { return err } @@ -290,7 +296,7 @@ func (it cliItem) Upgrade(cmd *cobra.Command, args []string) error { } } - log.Infof("Updated %d %s", updated, it.name) + log.Infof("Updated %d %s", updated, cli.name) if updated > 0 { log.Infof(ReloadMessage()) @@ -300,15 +306,15 @@ func (it cliItem) Upgrade(cmd *cobra.Command, args []string) error { } if len(args) == 0 { - return fmt.Errorf("specify at least one %s to upgrade or '--all'", it.singular) + return fmt.Errorf("specify at least one %s to upgrade or '--all'", cli.singular) } updated := 0 for _, itemName := range args { - item := hub.GetItem(it.name, itemName) + item := hub.GetItem(cli.name, itemName) if item == nil { - return fmt.Errorf("can't find '%s' in %s", itemName, it.name) + return fmt.Errorf("can't find '%s' in %s", itemName, cli.name) } didUpdate, err := item.Upgrade(force) @@ -328,27 +334,27 @@ func (it cliItem) Upgrade(cmd *cobra.Command, args []string) error { return nil } -func (it cliItem) NewUpgradeCmd() *cobra.Command { +func (cli cliItem) NewUpgradeCmd() *cobra.Command { cmd := &cobra.Command{ - Use: coalesce.String(it.upgradeHelp.use, "upgrade [item]..."), - Short: coalesce.String(it.upgradeHelp.short, fmt.Sprintf("Upgrade given %s", it.oneOrMore)), - Long: coalesce.String(it.upgradeHelp.long, fmt.Sprintf("Fetch and upgrade one or more %s from the hub", it.name)), - Example: it.upgradeHelp.example, + Use: coalesce.String(cli.upgradeHelp.use, "upgrade [item]..."), + Short: coalesce.String(cli.upgradeHelp.short, fmt.Sprintf("Upgrade given %s", cli.oneOrMore)), + Long: coalesce.String(cli.upgradeHelp.long, fmt.Sprintf("Fetch and upgrade one or more %s from the hub", cli.name)), + Example: cli.upgradeHelp.example, DisableAutoGenTag: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return compInstalledItems(it.name, args, toComplete) + return compInstalledItems(cli.name, args, toComplete) }, - RunE: it.Upgrade, + RunE: cli.Upgrade, } flags := cmd.Flags() - flags.BoolP("all", "a", false, fmt.Sprintf("Upgrade all the %s", it.name)) + flags.BoolP("all", "a", false, fmt.Sprintf("Upgrade all the %s", cli.name)) flags.Bool("force", false, "Force upgrade: overwrite tainted and outdated files") return cmd } -func (it cliItem) Inspect(cmd *cobra.Command, args []string) error { +func (cli cliItem) Inspect(cmd *cobra.Command, args []string) error { flags := cmd.Flags() url, err := flags.GetString("url") @@ -360,27 +366,50 @@ func (it cliItem) Inspect(cmd *cobra.Command, args []string) error { csConfig.Cscli.PrometheusUrl = url } + diff, err := flags.GetBool("diff") + if err != nil { + return err + } + + rev, err := flags.GetBool("rev") + if err != nil { + return err + } + noMetrics, err := flags.GetBool("no-metrics") if err != nil { return err } - hub, err := require.Hub(csConfig, nil) + remote := (*cwhub.RemoteHubCfg)(nil) + + if diff { + remote = require.RemoteHub(csConfig) + } + + hub, err := require.Hub(csConfig, remote) if err != nil { return err } for _, name := range args { - item := hub.GetItem(it.name, name) + item := hub.GetItem(cli.name, name) if item == nil { - return fmt.Errorf("can't find '%s' in %s", name, it.name) + return fmt.Errorf("can't find '%s' in %s", name, cli.name) } + + if diff { + fmt.Println(cli.whyTainted(hub, item, rev)) + + continue + } + if err = InspectItem(item, !noMetrics); err != nil { return err } - if it.inspectDetail != nil { - if err = it.inspectDetail(item); err != nil { + if cli.inspectDetail != nil { + if err = cli.inspectDetail(item); err != nil { return err } } @@ -389,28 +418,49 @@ func (it cliItem) Inspect(cmd *cobra.Command, args []string) error { return nil } -func (it cliItem) NewInspectCmd() *cobra.Command { +func (cli cliItem) NewInspectCmd() *cobra.Command { cmd := &cobra.Command{ - Use: coalesce.String(it.inspectHelp.use, "inspect [item]..."), - Short: coalesce.String(it.inspectHelp.short, fmt.Sprintf("Inspect given %s", it.oneOrMore)), - Long: coalesce.String(it.inspectHelp.long, fmt.Sprintf("Inspect the state of one or more %s", it.name)), - Example: it.inspectHelp.example, + Use: coalesce.String(cli.inspectHelp.use, "inspect [item]..."), + Short: coalesce.String(cli.inspectHelp.short, fmt.Sprintf("Inspect given %s", cli.oneOrMore)), + Long: coalesce.String(cli.inspectHelp.long, fmt.Sprintf("Inspect the state of one or more %s", cli.name)), + Example: cli.inspectHelp.example, Args: cobra.MinimumNArgs(1), DisableAutoGenTag: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return compInstalledItems(it.name, args, toComplete) + return compInstalledItems(cli.name, args, toComplete) }, - RunE: it.Inspect, + PreRunE: func(cmd *cobra.Command, _ []string) error { + flags := cmd.Flags() + + diff, err := flags.GetBool("diff") + if err != nil { + return err + } + + rev, err := flags.GetBool("rev") + if err != nil { + return err + } + + if rev && !diff { + return fmt.Errorf("--rev can only be used with --diff") + } + + return nil + }, + RunE: cli.Inspect, } flags := cmd.Flags() flags.StringP("url", "u", "", "Prometheus url") + flags.Bool("diff", false, "Show diff with latest version (for tainted items)") + flags.Bool("rev", false, "Reverse diff output") flags.Bool("no-metrics", false, "Don't show metrics (when cscli.output=human)") return cmd } -func (it cliItem) List(cmd *cobra.Command, args []string) error { +func (cli cliItem) List(cmd *cobra.Command, args []string) error { flags := cmd.Flags() all, err := flags.GetBool("all") @@ -425,26 +475,26 @@ func (it cliItem) List(cmd *cobra.Command, args []string) error { items := make(map[string][]*cwhub.Item) - items[it.name], err = selectItems(hub, it.name, args, !all) + items[cli.name], err = selectItems(hub, cli.name, args, !all) if err != nil { return err } - if err = listItems(color.Output, []string{it.name}, items, false); err != nil { + if err = listItems(color.Output, []string{cli.name}, items, false); err != nil { return err } return nil } -func (it cliItem) NewListCmd() *cobra.Command { +func (cli cliItem) NewListCmd() *cobra.Command { cmd := &cobra.Command{ - Use: coalesce.String(it.listHelp.use, "list [item... | -a]"), - Short: coalesce.String(it.listHelp.short, fmt.Sprintf("List %s", it.oneOrMore)), - Long: coalesce.String(it.listHelp.long, fmt.Sprintf("List of installed/available/specified %s", it.name)), - Example: it.listHelp.example, + Use: coalesce.String(cli.listHelp.use, "list [item... | -a]"), + Short: coalesce.String(cli.listHelp.short, fmt.Sprintf("List %s", cli.oneOrMore)), + Long: coalesce.String(cli.listHelp.long, fmt.Sprintf("List of installed/available/specified %s", cli.name)), + Example: cli.listHelp.example, DisableAutoGenTag: true, - RunE: it.List, + RunE: cli.List, } flags := cmd.Flags() @@ -452,3 +502,75 @@ func (it cliItem) NewListCmd() *cobra.Command { return cmd } + +// return the diff between the installed version and the latest version +func (cli cliItem) itemDiff(item *cwhub.Item, reverse bool) (string, error) { + if !item.State.Installed { + return "", fmt.Errorf("'%s' is not installed", item.FQName()) + } + + latestContent, remoteURL, err := item.FetchLatest() + if err != nil { + return "", err + } + + localContent, err := os.ReadFile(item.State.LocalPath) + if err != nil { + return "", fmt.Errorf("while reading %s: %w", item.State.LocalPath, err) + } + + file1 := item.State.LocalPath + file2 := remoteURL + content1 := string(localContent) + content2 := string(latestContent) + if reverse { + file1, file2 = file2, file1 + content1, content2 = content2, content1 + } + + edits := myers.ComputeEdits(span.URIFromPath(file1), content1, content2) + diff := gotextdiff.ToUnified(file1, file2, content1, edits) + + return fmt.Sprintf("%s", diff), nil +} + +func (cli cliItem) whyTainted(hub *cwhub.Hub, item *cwhub.Item, reverse bool) string { + if !item.State.Installed { + return fmt.Sprintf("# %s is not installed", item.FQName()) + } + + if !item.State.Tainted { + return fmt.Sprintf("# %s is not tainted", item.FQName()) + } + + if len(item.State.TaintedBy) == 0 { + return fmt.Sprintf("# %s is tainted but we don't know why. please report this as a bug", item.FQName()) + } + + ret := []string{ + fmt.Sprintf("# Let's see why %s is tainted.", item.FQName()), + } + + for _, fqsub := range item.State.TaintedBy { + ret = append(ret, fmt.Sprintf("\n-> %s\n", fqsub)) + + sub, err := hub.GetItemFQ(fqsub) + if err != nil { + ret = append(ret, err.Error()) + } + + diff, err := cli.itemDiff(sub, reverse) + if err != nil { + ret = append(ret, err.Error()) + } + + if diff != "" { + ret = append(ret, diff) + } else if len(sub.State.TaintedBy) > 0 { + taintList := strings.Join(sub.State.TaintedBy, ", ") + ret = append(ret, fmt.Sprintf("# %s is tainted by %s", sub.FQName(), taintList)) + } + } + + return strings.Join(ret, "\n") +} diff --git a/cmd/crowdsec-cli/items.go b/cmd/crowdsec-cli/items.go index 6a91ab1f5..8b2450c47 100644 --- a/cmd/crowdsec-cli/items.go +++ b/cmd/crowdsec-cli/items.go @@ -57,6 +57,7 @@ func listItems(out io.Writer, itemTypes []string, items map[string][]*cwhub.Item switch csConfig.Cscli.Output { case "human": nothingToDisplay := true + for _, itemType := range itemTypes { if omitIfEmpty && len(items[itemType]) == 0 { continue @@ -64,6 +65,7 @@ func listItems(out io.Writer, itemTypes []string, items map[string][]*cwhub.Item listHubItemTable(out, "\n"+strings.ToUpper(itemType), items[itemType]) nothingToDisplay = false } + if nothingToDisplay { fmt.Println("No items to display") } @@ -84,14 +86,14 @@ func listItems(out io.Writer, itemTypes []string, items map[string][]*cwhub.Item for i, item := range items[itemType] { status := item.State.Text() - status_emo := item.State.Emoji() + statusEmo := item.State.Emoji() hubStatus[itemType][i] = itemHubStatus{ Name: item.Name, LocalVersion: item.State.LocalVersion, LocalPath: item.State.LocalPath, Description: item.Description, Status: status, - UTF8Status: fmt.Sprintf("%v %s", status_emo, status), + UTF8Status: fmt.Sprintf("%v %s", statusEmo, status), } } } diff --git a/go.mod b/go.mod index 82a8b501b..7fadbde73 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/buger/jsonparser v1.1.1 github.com/c-robinson/iplib v1.0.3 github.com/cespare/xxhash/v2 v2.2.0 + github.com/crowdsecurity/coraza/v3 v3.0.0-20231213144607-41d5358da94f github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26 github.com/crowdsecurity/go-cs-lib v0.0.5 github.com/crowdsecurity/grokky v0.2.1 @@ -53,6 +54,7 @@ require ( github.com/hashicorp/go-hclog v1.5.0 github.com/hashicorp/go-plugin v1.4.10 github.com/hashicorp/go-version v1.2.1 + github.com/hexops/gotextdiff v1.0.3 github.com/ivanpirog/coloredcobra v1.0.1 github.com/jackc/pgx/v4 v4.14.1 github.com/jarcoal/httpmock v1.1.0 @@ -83,16 +85,12 @@ require ( golang.org/x/crypto v0.16.0 golang.org/x/mod v0.11.0 golang.org/x/sys v0.15.0 + golang.org/x/text v0.14.0 google.golang.org/grpc v1.56.3 google.golang.org/protobuf v1.31.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 gopkg.in/yaml.v2 v2.4.0 -) - -require ( - github.com/crowdsecurity/coraza/v3 v3.0.0-20231213144607-41d5358da94f - golang.org/x/text v0.14.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.0 k8s.io/apiserver v0.28.4 @@ -183,6 +181,7 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/spf13/cast v1.3.1 // indirect diff --git a/go.sum b/go.sum index d5f126ace..14302b34c 100644 --- a/go.sum +++ b/go.sum @@ -98,8 +98,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/crowdsecurity/coraza/v3 v3.0.0-20231206171741-c5b03c916879 h1:dhAc0AelASC3BbfuLURJeai1LYgFNgpMds0KPd9whbo= -github.com/crowdsecurity/coraza/v3 v3.0.0-20231206171741-c5b03c916879/go.mod h1:jNww1Y9SujXQc89zDR+XOb70bkC7mZ6ep7iKhUBBsiI= github.com/crowdsecurity/coraza/v3 v3.0.0-20231213144607-41d5358da94f h1:FkOB9aDw0xzDd14pTarGRLsUNAymONq3dc7zhvsXElg= github.com/crowdsecurity/coraza/v3 v3.0.0-20231213144607-41d5358da94f/go.mod h1:TrU7Li+z2RHNrPy0TKJ6R65V6Yzpan2sTIRryJJyJso= github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26 h1:r97WNVC30Uen+7WnLs4xDScS/Ex988+id2k6mDf8psU= @@ -348,6 +346,8 @@ github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgC github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= @@ -612,8 +612,9 @@ github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/segmentio/kafka-go v0.4.45 h1:prqrZp1mMId4kI6pyPolkLsH6sWOUmDxmmucbL4WS6E= github.com/segmentio/kafka-go v0.4.45/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil/v3 v3.23.5 h1:5SgDCeQ0KW0S4N0znjeM/eFHXXOKyv2dVNgRq/c9P6Y= github.com/shirou/gopsutil/v3 v3.23.5/go.mod h1:Ng3Maa27Q2KARVJ0SPZF5NdrQSC3XHKP8IIWrHgMeLY= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -916,6 +917,7 @@ google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/cwhub/hub.go b/pkg/cwhub/hub.go index bd623e1f9..ef8e39e05 100644 --- a/pkg/cwhub/hub.go +++ b/pkg/cwhub/hub.go @@ -179,6 +179,28 @@ func (h *Hub) GetItem(itemType string, itemName string) *Item { return h.GetItemMap(itemType)[itemName] } +// GetItemFQ returns an item from hub based on its type and name (type:author/name). +func (h *Hub) GetItemFQ(itemFQName string) (*Item, error) { + // type and name are separated by a colon + parts := strings.Split(itemFQName, ":") + + if len(parts) != 2 { + return nil, fmt.Errorf("invalid item name %s", itemFQName) + } + + m := h.GetItemMap(parts[0]) + if m == nil { + return nil, fmt.Errorf("invalid item type %s", parts[0]) + } + + i := m[parts[1]] + if i == nil { + return nil, fmt.Errorf("item %s not found", parts[1]) + } + + return i, nil +} + // GetItemNames returns a slice of (full) item names for a given type // (eg. for collections: crowdsecurity/apache2 crowdsecurity/nginx). func (h *Hub) GetItemNames(itemType string) []string { diff --git a/pkg/cwhub/item.go b/pkg/cwhub/item.go index 7dbe3ebb3..8c71230f6 100644 --- a/pkg/cwhub/item.go +++ b/pkg/cwhub/item.go @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/enescakir/emoji" log "github.com/sirupsen/logrus" + "slices" ) const ( @@ -53,6 +54,7 @@ type ItemState struct { Downloaded bool `json:"downloaded"` UpToDate bool `json:"up_to_date"` Tainted bool `json:"tainted"` + TaintedBy []string `json:"tainted_by,omitempty" yaml:"tainted_by,omitempty"` BelongsToCollections []string `json:"belongs_to_collections,omitempty" yaml:"belongs_to_collections,omitempty"` } @@ -406,3 +408,36 @@ func (i *Item) versionStatus() int { func (i *Item) validPath(dirName, fileName string) bool { return (dirName+"/"+fileName == i.Name+".yaml") || (dirName+"/"+fileName == i.Name+".yml") } + +// FQName returns the fully qualified name of the item (ie. parsers:crowdsecurity/apache2-logs). +func (i *Item) FQName () string { + return fmt.Sprintf("%s:%s", i.Type, i.Name) +} + +// 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! +func (i *Item) addTaint(sub *Item) { + i.State.Tainted = true + taintedBy := sub.FQName() + + idx, ok := slices.BinarySearch(i.State.TaintedBy, taintedBy) + if ok { + return + } + + // insert the taintedBy in the slice + + i.State.TaintedBy = append(i.State.TaintedBy, "") + + copy(i.State.TaintedBy[idx+1:], i.State.TaintedBy[idx:]) + + i.State.TaintedBy[idx] = taintedBy + + log.Debugf("%s is tainted by %s", i.Name, taintedBy) + + // propagate the taint to the ancestors + + for _, ancestor := range i.Ancestors() { + ancestor.addTaint(sub) + } +} diff --git a/pkg/cwhub/iteminstall_test.go b/pkg/cwhub/iteminstall_test.go index cf17eede7..80a419ec5 100644 --- a/pkg/cwhub/iteminstall_test.go +++ b/pkg/cwhub/iteminstall_test.go @@ -106,6 +106,7 @@ func TestInstallParser(t *testing.T) { testTaint(hub, t, it) testUpdate(hub, t, it) testDisable(hub, t, it) + break } } @@ -128,6 +129,7 @@ func TestInstallCollection(t *testing.T) { testTaint(hub, t, it) testUpdate(hub, t, it) testDisable(hub, t, it) + break } } diff --git a/pkg/cwhub/itemremove.go b/pkg/cwhub/itemremove.go index a58bd3fa8..251685365 100644 --- a/pkg/cwhub/itemremove.go +++ b/pkg/cwhub/itemremove.go @@ -45,14 +45,15 @@ func (i *Item) disable(purge bool, force bool) (bool, error) { link, _ := i.installPath() return false, fmt.Errorf("link %s does not exist (override with --force or --purge)", link) } + didRemove = false } else if err != nil { return false, err } i.State.Installed = false - didPurge := false + if purge { if didPurge, err = i.purge(); err != nil { return didRemove, err diff --git a/pkg/cwhub/itemupgrade.go b/pkg/cwhub/itemupgrade.go index 07c83b3c4..e081712a4 100644 --- a/pkg/cwhub/itemupgrade.go +++ b/pkg/cwhub/itemupgrade.go @@ -115,31 +115,31 @@ func (i *Item) downloadLatest(overwrite bool, updateOnly bool) (string, error) { return ret, nil } -// fetch downloads the item from the hub, verifies the hash and returns the content. -func (i *Item) fetch() ([]byte, error) { +// FetchLatest downloads the latest item from the hub, verifies the hash and returns the content and the used url. +func (i *Item) FetchLatest() ([]byte, string, error) { url, err := i.hub.remote.urlTo(i.RemotePath) if err != nil { - return nil, fmt.Errorf("failed to build hub item request: %w", err) + return nil, "", fmt.Errorf("failed to build request: %w", err) } resp, err := hubClient.Get(url) if err != nil { - return nil, fmt.Errorf("while downloading %s: %w", url, err) + return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad http code %d for %s", resp.StatusCode, url) + return nil, "", fmt.Errorf("bad http code %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("while downloading %s: %w", url, err) + return nil, "", err } hash := sha256.New() if _, err = hash.Write(body); err != nil { - return nil, fmt.Errorf("while hashing %s: %w", i.Name, err) + return nil, "", fmt.Errorf("while hashing %s: %w", i.Name, err) } meow := hex.EncodeToString(hash.Sum(nil)) @@ -147,10 +147,10 @@ func (i *Item) fetch() ([]byte, error) { log.Errorf("Downloaded version doesn't match index, please 'hub update'") log.Debugf("got %s, expected %s", meow, i.Versions[i.Version].Digest) - return nil, fmt.Errorf("invalid download hash for %s", i.Name) + return nil, "", fmt.Errorf("invalid download hash for %s", i.Name) } - return body, nil + return body, url, nil } // download downloads the item from the hub and writes it to the hub directory. @@ -171,9 +171,9 @@ func (i *Item) download(overwrite bool) (string, error) { } } - body, err := i.fetch() + body, url, err := i.FetchLatest() if err != nil { - return "", err + return "", fmt.Errorf("while downloading %s: %w", url, err) } // all good, install diff --git a/pkg/cwhub/itemupgrade_test.go b/pkg/cwhub/itemupgrade_test.go index 4275e8f36..3d8f5bef3 100644 --- a/pkg/cwhub/itemupgrade_test.go +++ b/pkg/cwhub/itemupgrade_test.go @@ -189,7 +189,7 @@ func assertCollectionDepsInstalled(t *testing.T, hub *Hub, collection string) { t.Helper() c := hub.GetItem(COLLECTIONS, collection) - require.NoError(t, c.checkSubItemVersions()) + require.Empty(t, c.checkSubItemVersions()) } func pushUpdateToCollectionInHub() { diff --git a/pkg/cwhub/sync.go b/pkg/cwhub/sync.go index 18a93cef3..0451cfe86 100644 --- a/pkg/cwhub/sync.go +++ b/pkg/cwhub/sync.go @@ -7,13 +7,13 @@ import ( "io" "os" "path/filepath" - "slices" "sort" "strings" "github.com/Masterminds/semver/v3" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" + "slices" ) func isYAMLFileName(path string) bool { @@ -221,10 +221,12 @@ func (h *Hub) itemVisit(path string, f os.DirEntry, err error) error { if !info.inhub { log.Tracef("%s is a local file, skip", path) + item, err := newLocalItem(h, path, info) if err != nil { return err } + h.addItem(item) return nil @@ -295,14 +297,16 @@ func (h *Hub) itemVisit(path string, f os.DirEntry, err error) error { } // checkSubItemVersions checks for the presence, taint and version state of sub-items. -func (i *Item) checkSubItemVersions() error { +func (i *Item) checkSubItemVersions() []string { + warn := make([]string, 0) + if !i.HasSubItems() { - return nil + return warn } if i.versionStatus() != versionUpToDate { log.Debugf("%s dependencies not checked: not up-to-date", i.Name) - return nil + return warn } // ensure all the sub-items are installed, or tag the parent as tainted @@ -315,33 +319,42 @@ func (i *Item) checkSubItemVersions() error { continue } - if err := sub.checkSubItemVersions(); err != nil { + if w := sub.checkSubItemVersions(); len(w) > 0 { if sub.State.Tainted { - i.State.Tainted = true + i.addTaint(sub) + warn = append(warn, fmt.Sprintf("%s is tainted by %s", i.Name, sub.FQName())) } - return fmt.Errorf("dependency of %s: sub collection %s is broken: %w", i.Name, sub.Name, err) + warn = append(warn, w...) + + continue } if sub.State.Tainted { - i.State.Tainted = true - return fmt.Errorf("%s is tainted because %s:%s is tainted", i.Name, sub.Type, sub.Name) + i.addTaint(sub) + warn = append(warn, fmt.Sprintf("%s is tainted by %s", i.Name, sub.FQName())) + + continue } if !sub.State.Installed && i.State.Installed { - i.State.Tainted = true - return fmt.Errorf("%s is tainted because %s:%s is missing", i.Name, sub.Type, sub.Name) + i.addTaint(sub) + warn = append(warn, fmt.Sprintf("%s is tainted by missing %s", i.Name, sub.FQName())) + + continue } if !sub.State.UpToDate { i.State.UpToDate = false - return fmt.Errorf("dependency of %s: outdated %s:%s", i.Name, sub.Type, sub.Name) + warn = append(warn, fmt.Sprintf("%s is tainted by outdated %s", i.Name, sub.FQName())) + + continue } log.Tracef("checking for %s - tainted:%t uptodate:%t", sub.Name, i.State.Tainted, i.State.UpToDate) } - return nil + return warn } // syncDir scans a directory for items, and updates the Hub state accordingly. @@ -379,6 +392,23 @@ func insertInOrderNoCase(sl []string, value string) []string { return append(sl[:i], append([]string{value}, sl[i:]...)...) } +func removeDuplicates(sl []string) []string { + seen := make(map[string]struct{}, len(sl)) + j := 0 + + for _, v := range sl { + if _, ok := seen[v]; ok { + continue + } + + seen[v] = struct{}{} + sl[j] = v + j++ + } + + return sl[:j] +} + // localSync updates the hub state with downloaded, installed and local items. func (h *Hub) localSync() error { err := h.syncDir(h.local.InstallDir) @@ -411,8 +441,8 @@ func (h *Hub) localSync() error { vs := item.versionStatus() switch vs { case versionUpToDate: // latest - if err := item.checkSubItemVersions(); err != nil { - warnings = append(warnings, err.Error()) + if w := item.checkSubItemVersions(); len(w) > 0 { + warnings = append(warnings, w...) } case versionUpdateAvailable: // not up-to-date warnings = append(warnings, fmt.Sprintf("update for collection %s available (currently:%s, latest:%s)", item.Name, item.State.LocalVersion, item.Version)) @@ -420,14 +450,14 @@ func (h *Hub) localSync() error { warnings = append(warnings, fmt.Sprintf("collection %s is in the future (currently:%s, latest:%s)", item.Name, item.State.LocalVersion, item.Version)) case versionUnknown: if !item.State.IsLocal() { - warnings = append(warnings, fmt.Sprintf("collection %s is tainted (latest:%s)", item.Name, item.Version)) + warnings = append(warnings, fmt.Sprintf("collection %s is tainted by local changes (latest:%s)", item.Name, item.Version)) } } log.Debugf("installed (%s) - status: %d | installed: %s | latest: %s | full: %+v", item.Name, vs, item.State.LocalVersion, item.Version, item.Versions) } - h.Warnings = warnings + h.Warnings = removeDuplicates(warnings) return nil } @@ -469,7 +499,7 @@ func (i *Item) setVersionState(path string, inhub bool) error { } i.State.UpToDate = false - i.State.Tainted = true + i.addTaint(i) return nil } diff --git a/test/bats/20_hub.bats b/test/bats/20_hub.bats index 010431a13..13b9ac3e6 100644 --- a/test/bats/20_hub.bats +++ b/test/bats/20_hub.bats @@ -83,7 +83,7 @@ teardown() { refute_stderr --partial "tainted" rune -0 truncate -s0 "$CONFIG_DIR/parsers/s01-parse/sshd-logs.yaml" rune -0 cscli hub list - assert_stderr --partial "crowdsecurity/sshd is tainted because parsers:crowdsecurity/sshd-logs is tainted" + assert_stderr --partial "crowdsecurity/sshd is tainted by parsers:crowdsecurity/sshd-logs" } @test "loading hub reports tainted items (subitem is not installed)" { @@ -92,7 +92,7 @@ teardown() { refute_stderr --partial "tainted" rune -0 rm "$CONFIG_DIR/parsers/s01-parse/sshd-logs.yaml" rune -0 cscli hub list - assert_stderr --partial "crowdsecurity/sshd is tainted because parsers:crowdsecurity/sshd-logs is missing" + assert_stderr --partial "crowdsecurity/sshd is tainted by missing parsers:crowdsecurity/sshd-logs" } @test "cscli hub update" {