diff --git a/pkg/acquisition/modules/appsec/config.go b/pkg/acquisition/modules/appsec/config.go index a25eaee6edc..32eb7b07c54 100644 --- a/pkg/acquisition/modules/appsec/config.go +++ b/pkg/acquisition/modules/appsec/config.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" yaml "github.com/goccy/go-yaml" @@ -20,6 +21,8 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/apiclient/useragent" "github.com/crowdsecurity/crowdsec/pkg/appsec" "github.com/crowdsecurity/crowdsec/pkg/appsec/allowlists" + "github.com/crowdsecurity/crowdsec/pkg/cwhub" + "github.com/crowdsecurity/crowdsec/pkg/exprhelpers" "github.com/crowdsecurity/crowdsec/pkg/metrics" ) @@ -121,6 +124,41 @@ func loadCertPool(caCertPath string, logger log.FieldLogger) (*x509.CertPool, er return caCertPool, nil } +// resolveAppsecConfigEntry expands a single appsec_configs entry into a list of +// installed appsec-config item names. Entries without glob meta-characters are +// returned as-is so the per-name "no appsec-config found" error surfaces from +// AppsecConfig.Load. Entries containing '*' or '?' are matched against installed +// hub items using the same expression helper used elsewhere in the appsec stack. +func resolveAppsecConfigEntry(entry string, hub *cwhub.Hub) ([]string, error) { + if !strings.ContainsAny(entry, "*?") { + return []string{entry}, nil + } + + matches := []string{} + + for _, item := range hub.GetInstalledByType(cwhub.APPSEC_CONFIGS, true) { + tmpMatch, err := exprhelpers.Match(entry, item.Name) + if err != nil { + return nil, fmt.Errorf("unable to match %q against %q: %w", entry, item.Name, err) + } + + matched, ok := tmpMatch.(bool) + if !ok { + return nil, fmt.Errorf("unexpected match result type %T for %q against %q", tmpMatch, entry, item.Name) + } + + if matched { + matches = append(matches, item.Name) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no installed appsec-config matches pattern %q", entry) + } + + return matches, nil +} + func (w *Source) Configure(_ context.Context, yamlConfig []byte, logger *log.Entry, _ metrics.AcquisitionMetricsLevel) error { if w.hub == nil { return errors.New("appsec datasource requires a hub. this is a bug, please report") @@ -178,15 +216,29 @@ func (w *Source) Configure(_ context.Context, yamlConfig []byte, logger *log.Ent return fmt.Errorf("unable to load appsec_config: %w", err) } } else if w.config.AppsecConfig != "" { - if err := appsecCfg.Load(w.config.AppsecConfig, w.hub); err != nil { - return fmt.Errorf("unable to load appsec_config: %w", err) + names, err := resolveAppsecConfigEntry(w.config.AppsecConfig, w.hub) + if err != nil { + return fmt.Errorf("unable to resolve appsec_config entry %q: %w", w.config.AppsecConfig, err) } - } else if len(w.config.AppsecConfigs) > 0 { - for _, appsecConfig := range w.config.AppsecConfigs { - if err := appsecCfg.Load(appsecConfig, w.hub); err != nil { + + for _, name := range names { + if err := appsecCfg.Load(name, w.hub); err != nil { return fmt.Errorf("unable to load appsec_config: %w", err) } } + } else if len(w.config.AppsecConfigs) > 0 { + for _, entry := range w.config.AppsecConfigs { + names, err := resolveAppsecConfigEntry(entry, w.hub) + if err != nil { + return fmt.Errorf("unable to resolve appsec_config entry %q: %w", entry, err) + } + + for _, name := range names { + if err := appsecCfg.Load(name, w.hub); err != nil { + return fmt.Errorf("unable to load appsec_config: %w", err) + } + } + } } else { return errors.New("no appsec_config provided") } diff --git a/pkg/acquisition/modules/appsec/config_test.go b/pkg/acquisition/modules/appsec/config_test.go new file mode 100644 index 00000000000..007f5b8a9a8 --- /dev/null +++ b/pkg/acquisition/modules/appsec/config_test.go @@ -0,0 +1,147 @@ +package appsecacquisition + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/crowdsecurity/crowdsec/pkg/csconfig" + "github.com/crowdsecurity/crowdsec/pkg/cwhub" +) + +// newTestHub builds an in-memory hub from a JSON index. It mirrors the +// `testHub` helper used in pkg/cwhub but is duplicated here because that helper +// is unexported. +func newTestHub(t *testing.T, indexJSON string) *cwhub.Hub { + t.Helper() + + tempDir := t.TempDir() + local := &csconfig.LocalHubCfg{ + HubDir: filepath.Join(tempDir, "hub"), + HubIndexFile: filepath.Join(tempDir, "hub", ".index.json"), + InstallDir: filepath.Join(tempDir, "install"), + InstallDataDir: filepath.Join(tempDir, "data"), + } + + require.NoError(t, os.MkdirAll(local.HubDir, 0o755)) + require.NoError(t, os.MkdirAll(local.InstallDir, 0o755)) + require.NoError(t, os.MkdirAll(local.InstallDataDir, 0o755)) + require.NoError(t, os.WriteFile(local.HubIndexFile, []byte(indexJSON), 0o644)) + + hub, err := cwhub.NewHub(local, nil) + require.NoError(t, err) + require.NoError(t, hub.Load()) + + return hub +} + +// markInstalled flags an appsec-config hub item as installed by setting its +// LocalPath, which is what ItemState.IsInstalled() checks. +func markInstalled(t *testing.T, hub *cwhub.Hub, name string) { + t.Helper() + + item := hub.GetItem(cwhub.APPSEC_CONFIGS, name) + require.NotNilf(t, item, "appsec-config %q missing from test hub", name) + item.State.LocalPath = filepath.Join("/", "fake", "install", item.Type, name+".yaml") +} + +// hubFixture sets up a hub with three appsec-configs under crowdsecurity/* and +// one under custom/*. Two of the crowdsecurity items are marked installed; the +// custom one is also installed; one crowdsecurity item is left uninstalled to +// verify wildcard expansion ignores it. +const hubFixtureIndex = `{ + "appsec-configs": { + "crowdsecurity/vpatch": { + "path": "appsec-configs/crowdsecurity/vpatch.yaml", + "version": "1.0", + "versions": {"1.0": {"digest": "aa"}} + }, + "crowdsecurity/generic": { + "path": "appsec-configs/crowdsecurity/generic.yaml", + "version": "1.0", + "versions": {"1.0": {"digest": "bb"}} + }, + "crowdsecurity/uninstalled": { + "path": "appsec-configs/crowdsecurity/uninstalled.yaml", + "version": "1.0", + "versions": {"1.0": {"digest": "cc"}} + }, + "custom/my-config": { + "path": "appsec-configs/custom/my-config.yaml", + "version": "1.0", + "versions": {"1.0": {"digest": "dd"}} + } + } +}` + +func newPopulatedHub(t *testing.T) *cwhub.Hub { + t.Helper() + hub := newTestHub(t, hubFixtureIndex) + markInstalled(t, hub, "crowdsecurity/vpatch") + markInstalled(t, hub, "crowdsecurity/generic") + markInstalled(t, hub, "custom/my-config") + return hub +} + +func TestResolveAppsecConfigEntry_LiteralPassThrough(t *testing.T) { + hub := newPopulatedHub(t) + + // Literal entries are returned as-is without consulting the hub. This + // preserves the existing per-name "no appsec-config found for X" error + // path inside AppsecConfig.Load for typos. + got, err := resolveAppsecConfigEntry("crowdsecurity/vpatch", hub) + require.NoError(t, err) + assert.Equal(t, []string{"crowdsecurity/vpatch"}, got) + + got, err = resolveAppsecConfigEntry("does/not-exist", hub) + require.NoError(t, err) + assert.Equal(t, []string{"does/not-exist"}, got) +} + +func TestResolveAppsecConfigEntry_WildcardExpands(t *testing.T) { + hub := newPopulatedHub(t) + + got, err := resolveAppsecConfigEntry("crowdsecurity/*", hub) + require.NoError(t, err) + // Only installed items match; sorted (case-insensitive) order from the hub. + assert.Equal(t, []string{"crowdsecurity/generic", "crowdsecurity/vpatch"}, got) +} + +func TestResolveAppsecConfigEntry_WildcardMatchesAll(t *testing.T) { + hub := newPopulatedHub(t) + + got, err := resolveAppsecConfigEntry("*", hub) + require.NoError(t, err) + assert.Equal(t, []string{"crowdsecurity/generic", "crowdsecurity/vpatch", "custom/my-config"}, got) +} + +func TestResolveAppsecConfigEntry_QuestionMark(t *testing.T) { + hub := newPopulatedHub(t) + + // '?' matches a single character, so this should match neither + // "crowdsecurity/vpatch" nor "crowdsecurity/generic". + _, err := resolveAppsecConfigEntry("crowdsecurity/?", hub) + require.Error(t, err) + assert.Contains(t, err.Error(), "no installed appsec-config matches pattern") +} + +func TestResolveAppsecConfigEntry_NoMatchErrors(t *testing.T) { + hub := newPopulatedHub(t) + + _, err := resolveAppsecConfigEntry("nope/*", hub) + require.Error(t, err) + assert.Contains(t, err.Error(), `no installed appsec-config matches pattern "nope/*"`) +} + +func TestResolveAppsecConfigEntry_WildcardSkipsUninstalled(t *testing.T) { + hub := newPopulatedHub(t) + + got, err := resolveAppsecConfigEntry("crowdsecurity/*", hub) + require.NoError(t, err) + // "crowdsecurity/uninstalled" is in the index but not installed, so it + // must not appear in the expansion. + assert.NotContains(t, got, "crowdsecurity/uninstalled") +}