diff --git a/.docker/compose/docker-compose.playwright-local.yml b/.docker/compose/docker-compose.playwright-local.yml index da2fd6e7c..b15bb1751 100644 --- a/.docker/compose/docker-compose.playwright-local.yml +++ b/.docker/compose/docker-compose.playwright-local.yml @@ -76,8 +76,12 @@ services: tmpfs: # True tmpfs for E2E test data - fresh on every run, in-memory only # mode=1777 allows any user to write (container runs as non-root) - # 256M gives headroom for the backup service's 100MB disk-space check - - /app/data:size=256M,mode=1777 + # 512M: the baseline data is ~85MB and each application backup the CrowdSec import UI + # creates is ~40MB (7 retained => ~280MB), so 256M filled up across repeated runs against + # one container (#1558). RAM is only used on demand. The backup service also needs 100MB + # free for its disk-space check. The specs delete their own backups (best effort); this + # size is the backstop when a run is interrupted. + - /app/data:size=512M,mode=1777 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests labels: diff --git a/backend/internal/api/handlers/crowdsec_data_mutation_test.go b/backend/internal/api/handlers/crowdsec_data_mutation_test.go index 997cdf697..16e6e25d2 100644 --- a/backend/internal/api/handlers/crowdsec_data_mutation_test.go +++ b/backend/internal/api/handlers/crowdsec_data_mutation_test.go @@ -84,6 +84,27 @@ func assertEngineStateIntact(t *testing.T, dir string) { require.Equal(t, "archive", readTreeFile(t, filepath.Join(dir, "hub_cache", "x", "bundle.tgz"))) } +func TestImportConfigSkipsMixedCaseEngineOwnedEntries(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedEngineState(t, dir) + _, r := newImportRouter(t, dir) + + w := httptest.NewRecorder() + r.ServeHTTP(w, importRequest(t, map[string]string{ + "config.yaml": importableConfig, + "Data/crowdsec.db": "evil-lapi", + "HUB_CACHE/y": "evil", + "CrowdSec.DB-WAL": "evil", + })) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + assertEngineStateIntact(t, dir) + require.NoDirExists(t, filepath.Join(dir, "Data")) + require.NoDirExists(t, filepath.Join(dir, "HUB_CACHE")) + require.NoFileExists(t, filepath.Join(dir, "CrowdSec.DB-WAL")) +} + func TestImportConfigReplacesConfigButPreservesEngineStateAndDataDir(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -250,7 +271,7 @@ func TestDataMutationsSerializeOnHandlerLock(t *testing.T) { } for name, do := range requests { - c.handler.dataMu.Lock() + holdLock(&c.handler.dataMu) done := make(chan *httptest.ResponseRecorder, 1) go func() { done <- do() }() @@ -277,7 +298,7 @@ func TestImportConfigWaitsForHandlerLock(t *testing.T) { seedEngineState(t, dir) h, r := newImportRouter(t, dir) - h.dataMu.Lock() + holdLock(&h.dataMu) done := make(chan int, 1) go func() { w := httptest.NewRecorder() diff --git a/backend/internal/api/handlers/crowdsec_export_import_test.go b/backend/internal/api/handlers/crowdsec_export_import_test.go index f3dceaced..6e78bb4d8 100644 --- a/backend/internal/api/handlers/crowdsec_export_import_test.go +++ b/backend/internal/api/handlers/crowdsec_export_import_test.go @@ -179,7 +179,6 @@ func TestImportConfigLayouts(t *testing.T) { t.Parallel() cases := map[string]map[string]string{ "config only": {"config/config.yaml": importableConfig}, - "root layout": {"config.yaml": importableConfig, "other.yaml": "o"}, "real and extra": {"config/config.yaml": importableConfig, "config/acquis.yaml": "a"}, } for name, files := range cases { @@ -270,7 +269,7 @@ func TestImportConfigFailureKeepsSecretsAndRestoresConfig(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, importRequest(t, map[string]string{"foo/config.yaml": importableConfig})) require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) - require.Contains(t, w.Body.String(), "config.yaml was not found at the top level or in config/") + require.Contains(t, w.Body.String(), "validation failed: required file missing: config.yaml") require.Equal(t, importableConfig, readTreeFile(t, filepath.Join(dir, "config", "config.yaml"))) assertLiveSecretsIntact(t, dir) }) @@ -501,7 +500,7 @@ func TestEditorHidesPidBouncersAndHub(t *testing.T) { } { require.Equal(t, readable, isReadable(rel), rel) } - require.True(t, isWritable("config/Hub/x.yaml")) + require.False(t, isWritable("config/Hub/x.yaml"), "hub paths are protected whatever the letter case") require.False(t, isWritable("config/hub/x.yaml")) require.False(t, isWritable("hub/x.yaml")) } diff --git a/backend/internal/api/handlers/crowdsec_files.go b/backend/internal/api/handlers/crowdsec_files.go index 35a967973..bb8847dfe 100644 --- a/backend/internal/api/handlers/crowdsec_files.go +++ b/backend/internal/api/handlers/crowdsec_files.go @@ -374,7 +374,9 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) { return } - h.dataMu.Lock() + if !h.lockData(c, "file write") { + return + } defer h.dataMu.Unlock() target, ferr := resolveWritable(h.DataDir, rel) diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 378609aeb..247e343c2 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -85,7 +85,8 @@ type CrowdsecHandler struct { // dataMu serializes every operation that mutates DataDir (preset apply, config import, file write) // and the export build; an export holds it only while writing its temporary file, never while sending. // Lock order is always dataMu first, then HubService.mu. - dataMu sync.Mutex + // The wait is abandoned when the request ends (see lockData). + dataMu ctxMutex // restore holds the state of the hub item restore action (crowdsec_hub_items.go). restore hubRestoreState @@ -228,21 +229,36 @@ func (v *ConfigArchiveValidator) Validate(path string) error { } for _, required := range v.RequiredFiles { - found := false - for _, file := range contents { - if filepath.Base(file) == required || file == required { - found = true - break - } - } - if !found { - return fmt.Errorf("required file missing: %s", required) + if err := requireArchiveFile(contents, required); err != nil { + return err } } return nil } +// requireArchiveFile checks that the archive holds file at the top level or in config/, after the same +// normalisation the extractor applies ("./a", "a//b" and "b/../a" are spelled alike). A copy in any +// other folder, such as parsers/config.yaml, does not count. An entry that would leave the destination +// is reported as invalid instead of missing. +func requireArchiveFile(contents []string, file string) error { + var escaped string + for _, name := range contents { + rel, ok := crowdsec.ArchiveEntryRel(name) + if !ok { + escaped = name + continue + } + if rel = filepath.ToSlash(rel); rel == file || rel == "config/"+file { + return nil + } + } + if escaped != "" { + return fmt.Errorf("invalid file path: %s", escaped) + } + return fmt.Errorf("required file missing: %s", file) +} + // errArchiveFormat is the fixed message for any upload that is not a .tar.gz archive. var errArchiveFormat = errors.New("only .tar.gz archives are supported") @@ -325,6 +341,17 @@ func listArchiveContents(path string) ([]string, error) { return files, nil } +const ( + // msgKeptProfileUndefined and msgKeptProfileDuplicate refuse an import without profiles.yaml whose + // kept profile no longer matches the notification files. The undefined names follow the prefix. + msgKeptProfileUndefined = "the archive has no profiles.yaml, so the current one was kept, but it names notifications the archive has no files for: " + msgKeptProfileDuplicate = "the archive has no profiles.yaml, so the current one was kept, but a notification file it needs defines a name the archive already defines" +) + +// msgImportFlatArchive is the fixed message for an archive whose configuration is not in the folder a +// nested installation reads. +const msgImportFlatArchive = "the archive keeps its configuration at the top level, but this CrowdSec installation reads it from the config folder; export a configuration from this page, or put the files in a config/ folder inside the archive" + // errConfigFileNotFound is the fixed message for an import that contains no config.yaml in a // location the engine reads (top level or config/). var errConfigFileNotFound = errors.New("config.yaml was not found at the top level or in config/") @@ -733,9 +760,22 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { } // Serialize with preset applies and file writes, then snapshot (copy, never rename) the config. - h.dataMu.Lock() + if !h.lockData(c, "import") { + return + } defer h.dataMu.Unlock() + // A nested installation reads config/config.yaml: refuse an archive that cannot provide it before + // anything is snapshotted or changed. When the inspection was skipped the check after extraction + // enforces the same rule. + liveLayout := crowdsec.DetectLayout(h.DataDir) + if inspectErr == nil { + if err = crowdsec.CheckImportLayout(liveLayout, info); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": msgImportFlatArchive}) + return + } + } + backupDir, err := crowdsec.Snapshot(h.DataDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"}) @@ -769,6 +809,29 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { return } + if liveLayout == crowdsec.LayoutNested { + if _, statErr := os.Stat(filepath.Join(h.DataDir, "config", "config.yaml")); statErr != nil { + rollback() + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": msgImportFlatArchive}) + return + } + } + + // The import cleared every file the archive did not replace: put back the ones CrowdSec cannot start + // without, and make sure a kept profile still resolves its notifications. + keptFiles, restoreErr := crowdsec.RestoreMissingCompanions(backupDir, h.DataDir, liveConfigPrefix(liveLayout)) + if restoreErr != nil { + logger.Log().WithField("error", sanitizeForLog(restoreErr.Error())).Warn("crowdsec import: restoring the current files failed") + rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to keep the current CrowdSec files"}) + return + } + if verifyErr := crowdsec.VerifyKeptProfile(h.DataDir, keptFiles); verifyErr != nil { + rollback() + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": keptProfileRefusal(verifyErr)}) + return + } + // Validate extracted config validateErr := errConfigFileNotFound if configPath := crowdsec.FindConfigFile(h.DataDir); configPath != "" { @@ -782,9 +845,30 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { resp := gin.H{"status": "imported", "backup": crowdsec.BackupID(backupDir)} h.addHubItemsReport(resp, info.HubItems) + if len(keptFiles) > 0 { + resp["kept_files"] = keptFiles + } c.JSON(http.StatusOK, resp) } +// liveConfigPrefix is the folder below DataDir that holds the configuration CrowdSec reads. +func liveConfigPrefix(l crowdsec.Layout) string { + if l == crowdsec.LayoutNested { + return "config" + } + return "" +} + +// keptProfileRefusal maps a failure of crowdsec.VerifyKeptProfile (an undefined or a duplicate +// notification) to its fixed 422 message. +func keptProfileRefusal(err error) string { + var undefined *crowdsec.UndefinedNotificationsError + if errors.As(err, &undefined) { + return msgKeptProfileUndefined + undefined.NameList() + } + return msgKeptProfileDuplicate +} + // pruneSnapshots bounds the full-tree snapshots kept next to DataDir; failures are logged only. func (h *CrowdsecHandler) pruneSnapshots() { if _, err := crowdsec.PruneBackups(h.DataDir, crowdsec.BackupKindSnapshot, crowdsec.DefaultBackupRetention); err != nil { @@ -927,9 +1011,23 @@ func (h *CrowdsecHandler) buildExport(ctx context.Context, tmp *os.File) (int64, return info.Size(), nil } +// lockData takes dataMu, giving up when the request ends. On false the lock is NOT held and the request +// is aborted without a body (the client is gone), so the caller must return before registering its +// deferred Unlock. +func (h *CrowdsecHandler) lockData(c *gin.Context, op string) bool { + if err := h.dataMu.LockContext(c.Request.Context()); err != nil { + logger.Log().WithField("operation", op).WithField("error", sanitizeForLog(err.Error())).Debug("crowdsec: request ended while waiting for the data lock") + c.Abort() + return false + } + return true +} + // writeExportLocked serializes the archive build with every other writer of DataDir. func (h *CrowdsecHandler) writeExportLocked(ctx context.Context, w io.Writer) (int64, error) { - h.dataMu.Lock() + if err := h.dataMu.LockContext(ctx); err != nil { + return 0, fmt.Errorf("wait for the data lock: %w", err) + } defer h.dataMu.Unlock() return crowdsec.WriteExportArchive(ctx, h.DataDir, w) } diff --git a/backend/internal/api/handlers/crowdsec_hub_items.go b/backend/internal/api/handlers/crowdsec_hub_items.go index 087fa2c70..44019388e 100644 --- a/backend/internal/api/handlers/crowdsec_hub_items.go +++ b/backend/internal/api/handlers/crowdsec_hub_items.go @@ -58,6 +58,9 @@ const ( msgRestoreRunning = "a restore is already running" msgRestoreHubDown = "the CrowdSec hub could not be reached; nothing was installed" msgRestoreReadFailed = "the installed hub items could not be read; nothing was installed" + // msgRestoreReadFailedPartial is used when the list could not be read again after the first pass + // had already run installs. + msgRestoreReadFailedPartial = "the installed hub items could not be read; some items may already have been installed" ) // hubRestoreLimits are the time budgets of one restore. The zero value means the production budgets. @@ -98,6 +101,13 @@ type hubRestoreFailure struct { Reason string `json:"reason"` } +// hubRestoreReadError is the 500 body of a restore whose second read of the installed items failed: +// the message plus everything that had already happened. +type hubRestoreReadError struct { + Error string `json:"error"` + *hubRestoreResponse +} + type hubRestoreResponse struct { Installed []crowdsec.HubItem `json:"installed"` AlreadyInstalled []crowdsec.HubItem `json:"already_installed"` @@ -154,20 +164,24 @@ func (h *CrowdsecHandler) RestoreHubItems(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": msgRestoreReadFailed}) return } - if !h.restorePass(ctx, limits.item, others, res) { - for _, it := range others { - res.Failed = append(res.Failed, hubRestoreFailure{it.Type, it.Name, hubReasonNotAttempted}) + readOK := h.restorePass(ctx, limits.item, others, res) + if len(res.Installed) > 0 { + res.Reloaded = h.reloadAfterRestore(ctx) + } + if !readOK { + // Nothing is reported as not attempted: the client sees what happened and a retry re-diffs. + msg := msgRestoreReadFailed + if len(collections) > 0 { + msg = msgRestoreReadFailedPartial } + c.JSON(http.StatusInternalServerError, hubRestoreReadError{Error: msg, hubRestoreResponse: res}) + return } for _, f := range res.Failed { if f.Reason == hubReasonNotAttempted { res.Remaining++ } } - - if len(res.Installed) > 0 { - res.Reloaded = h.reloadAfterRestore(ctx) - } c.JSON(http.StatusOK, res) } @@ -204,7 +218,9 @@ func parseHubRestoreRequest(c *gin.Context) (items []crowdsec.HubItem, status in // hubUpdate refreshes the hub index in its own locked section. func (h *CrowdsecHandler) hubUpdate(ctx context.Context, limit time.Duration) error { - h.dataMu.Lock() + if err := h.dataMu.LockContext(ctx); err != nil { + return fmt.Errorf("wait for the data lock: %w", err) + } defer h.dataMu.Unlock() uctx, cancel := context.WithTimeout(ctx, limit) defer cancel() @@ -216,8 +232,10 @@ func (h *CrowdsecHandler) hubUpdate(ctx context.Context, limit time.Duration) er // installedHubSet reads the installed items under dataMu, so it never observes a tree an import is // clearing. -func (h *CrowdsecHandler) installedHubSet() (map[crowdsec.HubItem]struct{}, error) { - h.dataMu.Lock() +func (h *CrowdsecHandler) installedHubSet(ctx context.Context) (map[crowdsec.HubItem]struct{}, error) { + if err := h.dataMu.LockContext(ctx); err != nil { + return nil, fmt.Errorf("wait for the data lock: %w", err) + } defer h.dataMu.Unlock() installed, err := crowdsec.InstalledHubItems(h.DataDir) if err != nil { @@ -231,13 +249,21 @@ func (h *CrowdsecHandler) installedHubSet() (map[crowdsec.HubItem]struct{}, erro } // restorePass installs the items of one pass that are not installed, recording every outcome in res. -// It returns false, having recorded nothing, when the installed items cannot be read. +// An end of ctx marks the items not attempted. It returns false, having recorded nothing, when the +// installed items cannot be read. func (h *CrowdsecHandler) restorePass(ctx context.Context, itemLimit time.Duration, items []crowdsec.HubItem, res *hubRestoreResponse) bool { if len(items) == 0 { return true } - installed, err := h.installedHubSet() + installed, err := h.installedHubSet(ctx) if err != nil { + if ctx.Err() != nil { + // The wait for the lock ran into the overall deadline or the client left. + for _, it := range items { + res.Failed = append(res.Failed, hubRestoreFailure{it.Type, it.Name, hubReasonNotAttempted}) + } + return true + } logger.Log().WithField("error", hubLogDetail(err.Error())).Warn("crowdsec hub items restore: cannot read installed items") return false } @@ -261,7 +287,9 @@ func (h *CrowdsecHandler) installHubItem(ctx context.Context, itemLimit time.Dur if ctx.Err() != nil { return hubReasonNotAttempted } - h.dataMu.Lock() + if err := h.dataMu.LockContext(ctx); err != nil { + return hubReasonNotAttempted + } defer h.dataMu.Unlock() // The lock wait may have used up the budget or the client may have left meanwhile. @@ -283,7 +311,7 @@ func (h *CrowdsecHandler) installHubItem(ctx context.Context, itemLimit time.Dur WithField("type", hubLogDetail(it.Type)).WithField("name", hubLogDetail(it.Name)). WithField("error", hubLogDetail(err.Error())).WithField("output", hubLogDetail(string(out))). Warn("crowdsec hub items restore: install failed") - if hubItemNotFound(out) { + if hubItemNotFound(out, it) { return hubReasonNotFound } return hubReasonInstallFail @@ -298,11 +326,15 @@ func (h *CrowdsecHandler) installHubItem(ctx context.Context, itemLimit time.Dur return "" } -// hubItemNotFound reports whether cscli's output says the hub does not know the item. This is a -// best-effort match on cscli's wording; anything else is install_failed. -func hubItemNotFound(out []byte) bool { - s := strings.ToLower(string(out)) - return strings.Contains(s, "can't find") || strings.Contains(s, "not found") +// hubItemNotFound reports whether cscli's output says the hub does not know this very item. cscli +// v1.8.1 answers, with exit status 1: +// +// Error: cscli collections install: can't find 'x/y' in collections +// +// Only that sentence for the requested item and type matches; any other wording, including a future +// cscli rewording, is install_failed, which is the safe direction. +func hubItemNotFound(out []byte, it crowdsec.HubItem) bool { + return strings.Contains(string(out), fmt.Sprintf("can't find '%s' in %s", it.Name, it.Type)) } // reloadAfterRestore asks CrowdSec to reload on a detached, bounded context so an install that succeeded diff --git a/backend/internal/api/handlers/crowdsec_hub_restore_test.go b/backend/internal/api/handlers/crowdsec_hub_restore_test.go index 6edf6e8b4..4a5aaaa7c 100644 --- a/backend/internal/api/handlers/crowdsec_hub_restore_test.go +++ b/backend/internal/api/handlers/crowdsec_hub_restore_test.go @@ -166,9 +166,11 @@ func decodeRestore(t *testing.T, w *httptest.ResponseRecorder) hubRestoreRespons func restoreErr(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() - var out map[string]string + var out struct { + Error string `json:"error"` + } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out), w.Body.String()) - return out["error"] + return out.Error } func TestRestoreHubItemsRejectsInvalidInput(t *testing.T) { @@ -537,7 +539,7 @@ func TestRestoreHubItemsHungInstallFreesTheLockWithinTheItemTimeout(t *testing.T acquired := make(chan struct{}) start := time.Now() go func() { - hh.h.dataMu.Lock() + holdLock(&hh.h.dataMu) close(acquired) hh.h.dataMu.Unlock() }() @@ -612,7 +614,7 @@ func TestRestoreHubItemsReleasesTheLockBetweenItems(t *testing.T) { var startedAtAcquire atomic.Int64 acquired := make(chan struct{}) go func() { // another writer queues behind the first item - hh.h.dataMu.Lock() + holdLock(&hh.h.dataMu) startedAtAcquire.Store(int64(len(hh.exec.installCalls()))) close(acquired) hh.h.dataMu.Unlock() @@ -707,10 +709,106 @@ func TestRestoreHubItemsMaximumRequest(t *testing.T) { func TestHubItemNotFound(t *testing.T) { t.Parallel() - require.True(t, hubItemNotFound([]byte("Error: can't find 'a/b' in collections"))) - require.True(t, hubItemNotFound([]byte("item NOT FOUND"))) - require.False(t, hubItemNotFound([]byte("download failed"))) - require.False(t, hubItemNotFound(nil)) + // Verbatim cscli v1.8.1 output for an item the hub does not know. + for _, typ := range crowdsec.HubItemTypes { + it := crowdsec.HubItem{Type: typ, Name: "x/y"} + out := []byte("Error: cscli " + typ + " install: can't find 'x/y' in " + typ) + require.True(t, hubItemNotFound(out, it), typ) + } + it := crowdsec.HubItem{Type: "collections", Name: "x/y"} + require.False(t, hubItemNotFound([]byte("Error: cscli collections install: can't find 'other/item' in collections"), it), "another item's name") + require.False(t, hubItemNotFound([]byte("Error: cscli collections install: can't find 'x/y' in parsers"), it), "another type") + require.False(t, hubItemNotFound([]byte("exec: \"cscli\": executable file not found in $PATH"), it)) + require.False(t, hubItemNotFound([]byte("item NOT FOUND"), it)) + require.False(t, hubItemNotFound([]byte("download failed"), it)) + require.False(t, hubItemNotFound(nil, it)) +} + +func TestRestoreHubItemsMissingItemOutputIsClassified(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + hh.exec.install = func(_ context.Context, typ, name string) ([]byte, error) { + switch name { + case "x/gone": + return []byte("Error: cscli " + typ + " install: can't find 'x/gone' in " + typ), errors.New("exit status 1") + case "x/nobinary": + return []byte("exec: \"cscli\": executable file not found in $PATH"), errors.New("exit status 1") + } + return nil, nil + } + w := hh.post(restoreBody(hubItems("collections", "x/gone", "parsers", "x/gone", "scenarios", "x/nobinary")...)) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, []hubRestoreFailure{ + {"collections", "x/gone", "not_found"}, + {"parsers", "x/gone", "not_found"}, + {"scenarios", "x/nobinary", "install_failed"}, + }, decodeRestore(t, w).Failed) +} + +// breakInstalledScan makes the next read of the installed items fail: "config" becomes a file, so the +// hub directories below it cannot be scanned. +func breakInstalledScan(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.RemoveAll(filepath.Join(dir, "config"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config"), []byte("x"), 0o600)) +} + +func TestRestoreHubItemsSecondReadFailureKeepsThePartialResult(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + hh.exec.noLinkFor = map[string]bool{"x/coll": true} + hh.exec.install = func(_ context.Context, typ, _ string) ([]byte, error) { + if typ == "collections" { + breakInstalledScan(t, hh.dir) + } + return nil, nil + } + pre := crowdsec.HubItem{Type: "collections", Name: "x/already"} + coll := crowdsec.HubItem{Type: "collections", Name: "x/coll"} + linkHubItem(hh.dir, pre) + + w := hh.post(restoreBody(pre, coll, crowdsec.HubItem{Type: "parsers", Name: "x/solo"})) + require.Equal(t, http.StatusInternalServerError, w.Code, w.Body.String()) + var body struct { + Error string `json:"error"` + Installed []crowdsec.HubItem `json:"installed"` + AlreadyInstalled []crowdsec.HubItem `json:"already_installed"` + Failed []hubRestoreFailure `json:"failed"` + Reloaded bool `json:"reloaded"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, msgRestoreReadFailedPartial, body.Error) + require.Equal(t, []crowdsec.HubItem{coll}, body.Installed) + require.Equal(t, []crowdsec.HubItem{pre}, body.AlreadyInstalled) + require.Empty(t, body.Failed, "no item is reported as not attempted") + require.True(t, body.Reloaded, "what pass one installed still takes effect") + require.EqualValues(t, 1, hh.signal.Load()) + require.Equal(t, []string{"cscli collections install x/coll"}, hh.exec.installCalls()) +} + +func TestRestoreHubItemsSecondReadFailureWithoutInstallsDoesNotReload(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + // The collection fails to install and the installed items cannot be read afterwards. + hh.exec.install = func(_ context.Context, _, _ string) ([]byte, error) { + breakInstalledScan(t, hh.dir) + return []byte("Error: download failed"), errors.New("exit status 1") + } + w := hh.post(restoreBody(hubItems("collections", "x/coll", "parsers", "x/solo")...)) + require.Equal(t, http.StatusInternalServerError, w.Code, w.Body.String()) + require.Equal(t, msgRestoreReadFailedPartial, restoreErr(t, w)) + require.Zero(t, hh.signal.Load()) +} + +func TestRestoreHubItemsFirstReadFailureInstallsNothing(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + breakInstalledScan(t, hh.dir) + w := hh.post(restoreBody(hubItems("collections", "x/coll")...)) + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Equal(t, msgRestoreReadFailed, restoreErr(t, w)) + require.Empty(t, hh.exec.installCalls()) + require.Zero(t, hh.signal.Load()) } func TestHubRestoreLimitsDefaults(t *testing.T) { diff --git a/backend/internal/api/handlers/crowdsec_import_kept_test.go b/backend/internal/api/handlers/crowdsec_import_kept_test.go new file mode 100644 index 000000000..89b53ba33 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_import_kept_test.go @@ -0,0 +1,231 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Wikid82/charon/backend/internal/crowdsec" + "github.com/stretchr/testify/require" +) + +func keptFilesOf(t *testing.T, body []byte) []string { + t.Helper() + var resp struct { + Status string `json:"status"` + KeptFiles []string `json:"kept_files"` + } + require.NoError(t, json.Unmarshal(body, &resp)) + require.Equal(t, "imported", resp.Status) + return resp.KeptFiles +} + +// seedLiveCompanions adds the four required companions to a real-layout tree. +func seedLiveCompanions(t *testing.T, dir string) { + t.Helper() + writeTree(t, dir, map[string]string{ + "config/acquis.yaml": "live-acquis", + "config/profiles.yaml": "live-profiles\n", + "config/simulation.yaml": "live-sim", + "config/patterns/linux": "live-pattern", + }) +} + +func TestImportConfigKeepsCurrentCompanionsTheArchiveLacks(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + seedLiveCompanions(t, dir) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, []string{"acquis.yaml", "patterns", "profiles.yaml", "simulation.yaml"}, keptFilesOf(t, w.Body.Bytes())) + require.Equal(t, "live-acquis", readTreeFile(t, filepath.Join(dir, "config", "acquis.yaml"))) + require.Equal(t, "live-profiles\n", readTreeFile(t, filepath.Join(dir, "config", "profiles.yaml"))) + require.Equal(t, "live-sim", readTreeFile(t, filepath.Join(dir, "config", "simulation.yaml"))) + require.Equal(t, "live-pattern", readTreeFile(t, filepath.Join(dir, "config", "patterns", "linux"))) +} + +func TestImportConfigArchiveCompanionsWinAndAreNotListed(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + seedLiveCompanions(t, dir) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, map[string]string{ + "config/config.yaml": importableConfig, + "config/acquis.yaml": "archive-acquis", + "config/profiles.yaml": "name: p\nfilters:\n - Alert.Remediation == true\ndecisions:\n - type: ban\n duration: 4h\n", + "config/patterns/custom": "archive-pattern", + }) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, []string{"simulation.yaml"}, keptFilesOf(t, w.Body.Bytes())) + require.Equal(t, "archive-acquis", readTreeFile(t, filepath.Join(dir, "config", "acquis.yaml"))) + require.NoFileExists(t, filepath.Join(dir, "config", "patterns", "linux")) +} + +func TestImportConfigWithoutCompanionsToKeepOmitsKeptFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + require.NoError(t, os.Remove(filepath.Join(dir, "config", "acquis.yaml"))) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.NotContains(t, body, "kept_files") +} + +func TestImportConfigFlatLiveTreeWithNestedArchive(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTree(t, dir, map[string]string{"config.yaml": "old: true\n", "acquis.yaml": "flat-acquis"}) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, []string{"acquis.yaml"}, keptFilesOf(t, w.Body.Bytes())) + require.Equal(t, "flat-acquis", readTreeFile(t, filepath.Join(dir, "acquis.yaml"))) + require.NoFileExists(t, filepath.Join(dir, "config", "acquis.yaml")) +} + +func TestImportConfigLinkedCompanionIsKeptUntouched(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + outside := t.TempDir() + writeTree(t, outside, map[string]string{"profiles.yaml": "outside-profiles", "patterns/linux": "outside"}) + require.NoError(t, os.Remove(filepath.Join(dir, "config", "acquis.yaml"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "profiles.yaml"), filepath.Join(dir, "config", "profiles.yaml"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "patterns"), filepath.Join(dir, "config", "patterns"))) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.NotContains(t, body, "kept_files") + require.Equal(t, "outside-profiles", readTreeFile(t, filepath.Join(outside, "profiles.yaml"))) + require.Equal(t, "outside", readTreeFile(t, filepath.Join(outside, "patterns", "linux"))) + target, err := os.Readlink(filepath.Join(dir, "config", "profiles.yaml")) + require.NoError(t, err) + require.Equal(t, filepath.Join(outside, "profiles.yaml"), target) +} + +func seedLiveProfileWithNotification(t *testing.T, dir string) { + t.Helper() + writeTree(t, dir, map[string]string{ + "config/profiles.yaml": strings.Replace(importProfilesNaming, "%s", "alpha", 1), + "config/notifications/alpha.yaml": strings.Replace(importNotification, "%s", "alpha", 1), + }) +} + +func TestImportConfigRestoresNotificationFilesTheKeptProfileNeeds(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + seedLiveProfileWithNotification(t, dir) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, []string{"acquis.yaml", "notifications/alpha.yaml", "profiles.yaml"}, keptFilesOf(t, w.Body.Bytes())) + require.Equal(t, strings.Replace(importNotification, "%s", "alpha", 1), readTreeFile(t, filepath.Join(dir, "config", "notifications", "alpha.yaml"))) +} + +func TestImportConfigRefusesKeptProfileWithUndefinedNotification(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + seedLiveProfileWithNotification(t, dir) + // The archive's own file of the same name defines another notification and must not be replaced. + _, r := newImportRouter(t, dir) + before := treeState(t, dir) + + w := importStatus(t, r, map[string]string{ + "config/config.yaml": importableConfig, + "config/notifications/alpha.yaml": strings.Replace(importNotification, "%s", "other", 1), + }) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.Equal(t, msgKeptProfileUndefined+"alpha", importNotificationBody(t, w)) + requireSameTree(t, before, dir) +} + +func TestImportConfigRefusesKeptProfileWhenRestoredFileClashes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + writeTree(t, dir, map[string]string{ + "config/profiles.yaml": strings.Replace(importProfilesNaming, "%s", "alpha", 1), + "config/notifications/alpha.yaml": strings.Replace(importNotification, "%s", "alpha", 1) + "---\n" + strings.Replace(importNotification, "%s", "beta", 1), + }) + _, r := newImportRouter(t, dir) + before := treeState(t, dir) + + w := importStatus(t, r, map[string]string{ + "config/config.yaml": importableConfig, + "config/notifications/b.yaml": strings.Replace(importNotification, "%s", "beta", 1), + }) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.Equal(t, msgKeptProfileDuplicate, importNotificationBody(t, w)) + requireSameTree(t, before, dir) +} + +func TestImportConfigRefusesKeptProfileNamingNoSnapshotFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + writeTree(t, dir, map[string]string{"config/profiles.yaml": strings.Replace(importProfilesNaming, "%s", "ghost", 1)}) + _, r := newImportRouter(t, dir) + before := treeState(t, dir) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.Equal(t, msgKeptProfileUndefined+"ghost", importNotificationBody(t, w)) + requireSameTree(t, before, dir) +} + +// requireSameTree asserts the tree below dir matches a treeState taken earlier. A rollback recreates +// directories with the snapshot's private mode, so directory permission bits are not compared. +func requireSameTree(t *testing.T, before map[string]string, dir string) { + t.Helper() + normalize := func(state map[string]string) map[string]string { + out := make(map[string]string, len(state)) + for rel, entry := range state { + if strings.HasPrefix(entry, "d") { + entry = "dir" + } + out[rel] = entry + } + return out + } + require.Equal(t, normalize(before), normalize(treeState(t, dir)), "a refused import must roll back to the same tree") +} + +func TestKeptProfileRefusal(t *testing.T) { + t.Parallel() + require.Equal(t, msgKeptProfileDuplicate, keptProfileRefusal(crowdsec.ErrDuplicateNotifications)) + require.Equal(t, msgKeptProfileUndefined+"a, b", keptProfileRefusal(&crowdsec.UndefinedNotificationsError{Names: []string{"a", "b"}})) +} + +func TestLiveConfigPrefix(t *testing.T) { + t.Parallel() + require.Equal(t, "config", liveConfigPrefix(1)) + require.Empty(t, liveConfigPrefix(0)) +} diff --git a/backend/internal/api/handlers/crowdsec_import_layout_test.go b/backend/internal/api/handlers/crowdsec_import_layout_test.go new file mode 100644 index 000000000..c37d5c6f3 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_import_layout_test.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func importStatus(t *testing.T, r http.Handler, files map[string]string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, importRequest(t, files)) + return w +} + +func requireNoSnapshots(t *testing.T, dir string) { + t.Helper() + matches, err := filepath.Glob(dir + ".backup.*") + require.NoError(t, err) + require.Empty(t, matches) +} + +func TestImportConfigRefusesFlatArchiveForNestedInstall(t *testing.T) { + t.Parallel() + flatArchives := map[string]map[string]string{ + "flat config": {"config.yaml": importableConfig, "other.yaml": "o"}, + "flat config, spelled with ./": {"./config.yaml": importableConfig}, + "flat config with config files": {"config.yaml": importableConfig, "config/acquis.yaml": "a"}, + } + for name, files := range flatArchives { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + seedRealLayout(t, dir) + _, r := newImportRouter(t, dir) + before := treeState(t, dir) + + w := importStatus(t, r, files) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.JSONEq(t, `{"error":"`+msgImportFlatArchive+`"}`, w.Body.String()) + require.Equal(t, before, treeState(t, dir)) + requireNoSnapshots(t, dir) + }) + } +} + +func TestImportConfigLayoutMatrix(t *testing.T) { + t.Parallel() + nested := map[string]string{"config/config.yaml": importableConfig} + flat := map[string]string{"config.yaml": importableConfig} + both := map[string]string{"config.yaml": importableConfig, "config/config.yaml": importableConfig} + cases := []struct { + name string + seed func(t *testing.T, dir string) + files map[string]string + want int + cfgFile string + }{ + {"nested live, nested archive", func(t *testing.T, d string) { seedRealLayout(t, d) }, nested, http.StatusOK, "config/config.yaml"}, + {"nested live, both layouts", func(t *testing.T, d string) { seedRealLayout(t, d) }, both, http.StatusOK, "config/config.yaml"}, + {"fresh tree, flat archive", func(*testing.T, string) {}, flat, http.StatusOK, "config.yaml"}, + {"fresh tree, nested archive", func(*testing.T, string) {}, nested, http.StatusOK, "config/config.yaml"}, + {"flat live, flat archive", func(t *testing.T, d string) { writeTree(t, d, map[string]string{"config.yaml": "old: true\n"}) }, flat, http.StatusOK, "config.yaml"}, + {"empty config folder, flat archive", func(t *testing.T, d string) { require.NoError(t, os.MkdirAll(filepath.Join(d, "config"), 0o750)) }, flat, http.StatusOK, "config.yaml"}, + {"hybrid tree, flat archive", func(t *testing.T, d string) { + writeTree(t, d, map[string]string{"config.yaml": "old: true\n", "config/local_api_credentials.yaml": "live"}) + }, flat, http.StatusUnprocessableEntity, ""}, + {"hybrid tree, nested archive", func(t *testing.T, d string) { + writeTree(t, d, map[string]string{"config.yaml": "old: true\n", "config/local_api_credentials.yaml": "live"}) + }, nested, http.StatusOK, "config/config.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tc.seed(t, dir) + _, r := newImportRouter(t, dir) + + w := importStatus(t, r, tc.files) + + require.Equal(t, tc.want, w.Code, w.Body.String()) + if tc.cfgFile != "" { + require.Equal(t, importableConfig, readTreeFile(t, filepath.Join(dir, filepath.FromSlash(tc.cfgFile)))) + } + }) + } +} + +// TestImportConfigNestedGuardAfterExtraction covers the check that backs up the archive inspection: a +// config folder that is a link is kept across the import, so the archive's config/config.yaml is never +// written and the install would be left without the file it reads. +func TestImportConfigNestedGuardAfterExtraction(t *testing.T) { + t.Parallel() + dir := t.TempDir() + target := filepath.Join(t.TempDir(), "etc-crowdsec") + writeTree(t, target, map[string]string{"acquis.yaml": "filenames: [x]\n"}) + require.NoError(t, os.Symlink(target, filepath.Join(dir, "config"))) + _, r := newImportRouter(t, dir) + before := treeState(t, target) + + w := importStatus(t, r, map[string]string{"config/config.yaml": importableConfig}) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.JSONEq(t, `{"error":"`+msgImportFlatArchive+`"}`, w.Body.String()) + require.Equal(t, before, treeState(t, target)) + require.NoFileExists(t, filepath.Join(target, "config.yaml")) +} + +func TestRequireArchiveFile(t *testing.T) { + t.Parallel() + cases := []struct { + name string + entries []string + want string + }{ + {"top level", []string{"config.yaml"}, ""}, + {"nested", []string{"config/config.yaml"}, ""}, + {"dot slash", []string{"./config.yaml"}, ""}, + {"dot slash nested", []string{"./config/config.yaml"}, ""}, + {"double slash", []string{"config//config.yaml"}, ""}, + {"dot segment", []string{"config/./config.yaml"}, ""}, + {"parent segment", []string{"x/../config.yaml"}, ""}, + {"other folder", []string{"parsers/config.yaml"}, "required file missing: config.yaml"}, + {"deeper in config", []string{"config/sub/config.yaml"}, "required file missing: config.yaml"}, + {"other case", []string{"Config.yaml"}, "required file missing: config.yaml"}, + {"similar name", []string{"myconfig.yaml", "config.yaml.bak"}, "required file missing: config.yaml"}, + {"no entries", nil, "required file missing: config.yaml"}, + {"leaves the destination", []string{"../config.yaml"}, "invalid file path: ../config.yaml"}, + {"leaves the destination but the file is there", []string{"../config.yaml", "config.yaml"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := requireArchiveFile(tc.entries, "config.yaml") + if tc.want == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, tc.want) + }) + } +} diff --git a/backend/internal/api/handlers/crowdsec_lock_cancel_test.go b/backend/internal/api/handlers/crowdsec_lock_cancel_test.go new file mode 100644 index 000000000..ffa725ccf --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_lock_cancel_test.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/crowdsec" +) + +// requireLockHeldThenReleasable asserts the holder's lock survived a cancelled waiter: it is still taken, +// and it can be released and taken again. +func requireLockHeldThenReleasable(t *testing.T, mu *ctxMutex) { + t.Helper() + require.False(t, mu.TryLock(), "the cancelled waiter must not have released the holder's lock") + mu.Unlock() + require.True(t, mu.TryLock(), "the lock must be free once the holder is done") + mu.Unlock() +} + +// TestCancelledWaitersReturnWithoutReleasingTheHoldersLock covers every gin site that waits for the data +// lock: while another request holds it, a request whose client leaves returns with no body. +func TestCancelledWaitersReturnWithoutReleasingTheHoldersLock(t *testing.T) { + c := newCuratedHarness(t, nil) + + requests := map[string]func(ctx context.Context) *http.Request{ + "apply": func(ctx context.Context) *http.Request { + body, _ := json.Marshal(map[string]string{"slug": "honeypot-friendly-defaults"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + return req + }, + "write": func(ctx context.Context) *http.Request { + body, _ := json.Marshal(map[string]string{"path": "locked.conf", "content": "x"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + return req + }, + "import": func(ctx context.Context) *http.Request { + return importRequest(t, map[string]string{"config.yaml": importableConfig}).WithContext(ctx) + }, + "export": func(ctx context.Context) *http.Request { + return httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody).WithContext(ctx) + }, + } + + for name, newReq := range requests { + t.Run(name, func(t *testing.T) { + holdLock(&c.handler.dataMu) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + w := httptest.NewRecorder() + c.router.ServeHTTP(w, newReq(ctx)) + done <- w + }() + + select { + case <-done: + c.handler.dataMu.Unlock() + t.Fatalf("%s returned while the lock was held and the client was still there", name) + case <-time.After(100 * time.Millisecond): + } + cancel() + select { + case w := <-done: + require.Empty(t, w.Body.String(), "a client that left gets no body") + case <-time.After(5 * time.Second): + c.handler.dataMu.Unlock() + t.Fatalf("%s kept waiting after the request was cancelled", name) + } + requireLockHeldThenReleasable(t, &c.handler.dataMu) + }) + } +} + +// TestRestoreLockSitesGiveUpOnCancelledContext covers the three waits of the restore action. +func TestRestoreLockSitesGiveUpOnCancelledContext(t *testing.T) { + t.Parallel() + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + item := crowdsec.HubItem{Type: "scenarios", Name: "a/one"} + + t.Run("hub update", func(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + holdLock(&hh.h.dataMu) + require.ErrorIs(t, hh.h.hubUpdate(cancelled, time.Second), context.Canceled) + require.Empty(t, hh.exec.callLog(), "no cscli call without the lock") + requireLockHeldThenReleasable(t, &hh.h.dataMu) + }) + t.Run("installed items", func(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + holdLock(&hh.h.dataMu) + _, err := hh.h.installedHubSet(cancelled) + require.ErrorIs(t, err, context.Canceled) + requireLockHeldThenReleasable(t, &hh.h.dataMu) + }) + t.Run("install", func(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + holdLock(&hh.h.dataMu) + ctx, stop := context.WithCancel(context.Background()) + defer stop() + done := make(chan string, 1) + go func() { done <- hh.h.installHubItem(ctx, time.Second, item) }() + select { + case <-done: + hh.h.dataMu.Unlock() + t.Fatal("install ran while the lock was held") + case <-time.After(100 * time.Millisecond): + } + stop() + select { + case reason := <-done: + require.Equal(t, hubReasonNotAttempted, reason) + case <-time.After(5 * time.Second): + hh.h.dataMu.Unlock() + t.Fatal("install kept waiting after the context ended") + } + require.Empty(t, hh.exec.callLog()) + requireLockHeldThenReleasable(t, &hh.h.dataMu) + }) +} + +// TestRestoreWaitCutShortMarksItemsNotAttempted: when the lock wait of a pass ends with the context, the +// pass reports its items as not attempted rather than as an unreadable install list. +func TestRestoreWaitCutShortMarksItemsNotAttempted(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + holdLock(&hh.h.dataMu) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + res := &hubRestoreResponse{} + items := hubItems("scenarios", "a/one", "parsers", "a/two") + + require.True(t, hh.h.restorePass(ctx, time.Second, items, res)) + require.Equal(t, []hubRestoreFailure{ + {"scenarios", "a/one", hubReasonNotAttempted}, + {"parsers", "a/two", hubReasonNotAttempted}, + }, res.Failed) + requireLockHeldThenReleasable(t, &hh.h.dataMu) +} + +// TestRestoreWhileDataLockHeldAnswersWhenClientLeaves: the whole action ends once the client is gone, even +// though an import-like holder keeps the lock. +func TestRestoreWhileDataLockHeldAnswersWhenClientLeaves(t *testing.T) { + t.Parallel() + hh := newHubHarness(t) + holdLock(&hh.h.dataMu) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + w := hh.postCtx(ctx, restoreBody(hubItems("scenarios", "a/one")...)) + require.Equal(t, http.StatusBadGateway, w.Code) + require.Equal(t, msgRestoreHubDown, restoreErr(t, w)) + require.False(t, hh.h.restore.running.Load(), "the restore slot is free again") + requireLockHeldThenReleasable(t, &hh.h.dataMu) +} diff --git a/backend/internal/api/handlers/crowdsec_preset_handler.go b/backend/internal/api/handlers/crowdsec_preset_handler.go index e22609ebf..89455d455 100644 --- a/backend/internal/api/handlers/crowdsec_preset_handler.go +++ b/backend/internal/api/handlers/crowdsec_preset_handler.go @@ -225,7 +225,9 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) { } // One mutation of DataDir at a time across preset apply, config import and file writes. - h.dataMu.Lock() + if !h.lockData(c, "preset apply") { + return + } defer h.dataMu.Unlock() // Check for curated preset that doesn't require hub diff --git a/backend/internal/api/handlers/ctxmutex.go b/backend/internal/api/handlers/ctxmutex.go new file mode 100644 index 000000000..976cd2605 --- /dev/null +++ b/backend/internal/api/handlers/ctxmutex.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "context" + "sync" +) + +// ctxMutex is a mutual-exclusion lock whose waiters can give up when their context ends. The zero +// value is an unlocked mutex, so it works as a plain struct field. A ctxMutex must not be copied. +// +// Unlike sync.Mutex it is not owned by a goroutine, but the contract is the same: only the holder +// calls Unlock, and a LockContext that returned an error did NOT take the lock, so the caller must +// return before any deferred Unlock is registered. +type ctxMutex struct { + once sync.Once + slot chan struct{} // holds one token while the lock is taken +} + +func (m *ctxMutex) init() { + m.once.Do(func() { m.slot = make(chan struct{}, 1) }) +} + +// TryLock takes the lock if it is free and reports whether it did. +func (m *ctxMutex) TryLock() bool { + m.init() + select { + case m.slot <- struct{}{}: + return true + default: + return false + } +} + +// LockContext waits for the lock until ctx ends. It returns ctx.Err() without having taken the lock +// when ctx ends first; an already-ended ctx never takes a free lock. +func (m *ctxMutex) LockContext(ctx context.Context) error { + m.init() + if err := ctx.Err(); err != nil { + return err + } + select { + case m.slot <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Unlock releases the lock and panics if it is not held, like sync.Mutex does (there as a fatal error). +func (m *ctxMutex) Unlock() { + m.init() + select { + case <-m.slot: + default: + panic("handlers: unlock of unlocked ctxMutex") + } +} diff --git a/backend/internal/api/handlers/ctxmutex_test.go b/backend/internal/api/handlers/ctxmutex_test.go new file mode 100644 index 000000000..65c3d7f6d --- /dev/null +++ b/backend/internal/api/handlers/ctxmutex_test.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// holdLock takes mu for a test, waiting as long as needed. +func holdLock(mu *ctxMutex) { + _ = mu.LockContext(context.Background()) +} + +func TestCtxMutexZeroValueLockUnlockTryLock(t *testing.T) { + t.Parallel() + var m ctxMutex + require.True(t, m.TryLock()) + require.False(t, m.TryLock(), "a held lock cannot be taken again") + m.Unlock() + holdLock(&m) + require.False(t, m.TryLock()) + m.Unlock() + require.True(t, m.TryLock()) + m.Unlock() +} + +func TestCtxMutexUnlockOfUnlockedPanics(t *testing.T) { + t.Parallel() + var m ctxMutex + require.Panics(t, m.Unlock) +} + +func TestCtxMutexLockContextFreeLock(t *testing.T) { + t.Parallel() + var m ctxMutex + require.NoError(t, m.LockContext(context.Background())) + require.False(t, m.TryLock()) + m.Unlock() +} + +func TestCtxMutexLockContextEndedContextDoesNotTakeAFreeLock(t *testing.T) { + t.Parallel() + var m ctxMutex + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, m.LockContext(ctx), context.Canceled) + require.True(t, m.TryLock(), "the failed wait must not have taken the lock") + m.Unlock() +} + +func TestCtxMutexCancelledWaiterLeavesTheHoldersLockHeld(t *testing.T) { + t.Parallel() + var m ctxMutex + holdLock(&m) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- m.LockContext(ctx) }() + select { + case err := <-done: + t.Fatalf("returned while the lock was held: %v", err) + case <-time.After(50 * time.Millisecond): + } + cancel() + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal("a cancelled waiter did not return") + } + require.False(t, m.TryLock(), "the holder's lock must still be held") + m.Unlock() + require.True(t, m.TryLock()) + m.Unlock() +} + +func TestCtxMutexLockContextDeadline(t *testing.T) { + t.Parallel() + var m ctxMutex + holdLock(&m) + defer m.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + require.ErrorIs(t, m.LockContext(ctx), context.DeadlineExceeded) +} + +func TestCtxMutexWaiterAcquiresAfterUnlock(t *testing.T) { + t.Parallel() + var m ctxMutex + holdLock(&m) + done := make(chan error, 1) + go func() { done <- m.LockContext(context.Background()) }() + m.Unlock() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("waiter did not acquire the released lock") + } + m.Unlock() +} + +func TestCtxMutexExcludesConcurrentSections(t *testing.T) { + t.Parallel() + var ( + m ctxMutex + wg sync.WaitGroup + count int + ) + for range 50 { + wg.Add(1) + go func() { + defer wg.Done() + if err := m.LockContext(context.Background()); err != nil { + return + } + defer m.Unlock() + count++ + }() + } + wg.Wait() + require.Equal(t, 50, count) +} diff --git a/backend/internal/crowdsec/archive_inspect.go b/backend/internal/crowdsec/archive_inspect.go index 2ac732549..85044cca4 100644 --- a/backend/internal/crowdsec/archive_inspect.go +++ b/backend/internal/crowdsec/archive_inspect.go @@ -11,8 +11,10 @@ import ( "path" "path/filepath" "strings" + "syscall" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/util" ) // ManifestEntryName is the archive entry reserved for the hub item manifest. Exports generate it, an @@ -35,6 +37,9 @@ type ArchiveInfo struct { Incomplete bool // HubItems is the usable hub item manifest, nil when the archive has none or it was rejected. HubItems *ArchiveHubItems + // HasNestedConfig reports a regular config/config.yaml entry. It is set whatever the entry size, + // unlike Files, which skips entries over the bounds. + HasNestedConfig bool } // ArchiveEntryRel normalizes a tar entry name exactly as the extractor places it below the destination @@ -108,10 +113,16 @@ func InspectArchive(archivePath string) (ArchiveInfo, error) { } continue } - if !ok || !inspectWanted(filepath.ToSlash(rel)) { + if !ok { continue } rel = filepath.ToSlash(rel) + if rel == "config/config.yaml" { + info.HasNestedConfig = true + } + if !inspectWanted(rel) { + continue + } if hdr.Size > maxInspectEntryBytes || total+hdr.Size > maxInspectTotalBytes || (len(info.Files) >= maxInspectEntries && info.Files[rel] == nil) { info.Incomplete = true continue @@ -170,3 +181,89 @@ func (m *manifestSlot) parse() *ArchiveHubItems { } return items } + +// InspectTree reads the same entries as InspectArchive from the files on disk below dataDir, in both +// layouts, under the same bounds. Links are followed, as CrowdSec follows them when it reads the tree; an +// entry that is not a regular file once resolved, or one that exceeds a bound, makes the result +// Incomplete so callers fail open on it. +func InspectTree(dataDir string) (ArchiveInfo, error) { + info := ArchiveInfo{Files: map[string][]byte{}} + root := filepath.Clean(dataDir) + var total int64 + for _, prefix := range []string{"", "config"} { + for _, name := range []string{"config.yaml", "profiles.yaml"} { + if err := addTreeFile(&info, &total, root, path.Join(prefix, name)); err != nil { + return info, err + } + } + dirRel := path.Join(prefix, "notifications") + dirInfo, err := os.Stat(filepath.Join(root, filepath.FromSlash(dirRel))) + switch { + case treeEntryAbsent(err): + continue + case err != nil: + return info, fmt.Errorf("inspect %s: %w", dirRel, withoutPath(err)) + case !dirInfo.IsDir(): + info.Incomplete = true + continue + } + entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dirRel))) + if err != nil { + return info, fmt.Errorf("read %s: %w", dirRel, withoutPath(err)) + } + for _, entry := range entries { + rel := path.Join(dirRel, entry.Name()) + if !inspectWanted(rel) { + continue + } + if err := addTreeFile(&info, &total, root, rel); err != nil { + return info, err + } + } + } + return info, nil +} + +// treeEntryAbsent reports whether a stat error means the entry does not exist (a file in the way of +// a parent directory counts as absent too). +func treeEntryAbsent(err error) bool { + return errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) +} + +// addTreeFile reads the wanted file rel (slash-separated, below root) into info when it is a regular +// file within the bounds. A missing file is not an error. +func addTreeFile(info *ArchiveInfo, total *int64, root, rel string) error { + full := filepath.Join(root, filepath.FromSlash(rel)) + st, err := os.Stat(full) + if treeEntryAbsent(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect %s: %w", rel, withoutPath(err)) + } + if !st.Mode().IsRegular() { + info.Incomplete = true + logger.Log().WithField("entry", util.SanitizeForLog(rel)).Debug("crowdsec tree inspection: entry is not a regular file") + return nil + } + if st.Size() > maxInspectEntryBytes || *total+st.Size() > maxInspectTotalBytes || len(info.Files) >= maxInspectEntries { + info.Incomplete = true + return nil + } + f, err := os.Open(full) // #nosec G304 -- full is a fixed name below the data directory, checked regular by Stat above + if err != nil { + return fmt.Errorf("open %s: %w", rel, withoutPath(err)) + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(io.LimitReader(f, maxInspectEntryBytes+1)) + if err != nil { + return fmt.Errorf("read %s: %w", rel, withoutPath(err)) + } + if int64(len(data)) > maxInspectEntryBytes { + info.Incomplete = true + return nil + } + *total += int64(len(data)) + info.Files[rel] = data + return nil +} diff --git a/backend/internal/crowdsec/backup.go b/backend/internal/crowdsec/backup.go index 16a430a2b..3a44d74d5 100644 --- a/backend/internal/crowdsec/backup.go +++ b/backend/internal/crowdsec/backup.go @@ -36,8 +36,9 @@ const ( // isLiveDBFile reports whether name is the live CrowdSec SQLite database or its WAL/SHM sidecars. // These are owned by the running engine and must never be copied into, or restored from, a backup. +// Matching is case-insensitive. func isLiveDBFile(name string) bool { - switch name { + switch strings.ToLower(name) { case "crowdsec.db", "crowdsec.db-wal", "crowdsec.db-shm": return true } @@ -49,9 +50,9 @@ func isLiveDBFile(name string) bool { // db file at any depth, the top-level crowdsec.pid (written by the executor, owned by the running // process), or anything under the top-level data/ (LAPI db and hub data files) or // hub_cache/ (regenerable download cache). Only the top-level directories are excluded; a nested -// directory with the same name stays part of the configuration. +// directory with the same name stays part of the configuration. Matching is case-insensitive. func IsEngineOwnedPath(rel string) bool { - rel = filepath.ToSlash(filepath.Clean(rel)) + rel = strings.ToLower(filepath.ToSlash(filepath.Clean(rel))) if rel == "." || rel == "" { return false } diff --git a/backend/internal/crowdsec/backup_test.go b/backend/internal/crowdsec/backup_test.go index 3191bbfbd..da881ce3f 100644 --- a/backend/internal/crowdsec/backup_test.go +++ b/backend/internal/crowdsec/backup_test.go @@ -64,11 +64,11 @@ func seedPersistentTree(t *testing.T) string { func TestIsEngineOwnedPath(t *testing.T) { t.Parallel() - owned := []string{"crowdsec.db", "crowdsec.db-wal", "crowdsec.db-shm", "config/crowdsec.db", "data", "data/x", "data/a/b", "hub_cache", "hub_cache/y", "./data/x"} + owned := []string{"DATA/crowdsec.db", "Hub_Cache", "Hub_Cache/y", "Data", "x/CROWDSEC.DB-WAL", "config/Crowdsec.Db-Shm", "crowdsec.db", "crowdsec.db-wal", "crowdsec.db-shm", "config/crowdsec.db", "data", "data/x", "data/a/b", "hub_cache", "hub_cache/y", "./data/x"} for _, p := range owned { require.True(t, IsEngineOwnedPath(p), p) } - notOwned := []string{"", ".", "config/data", "config/data/x", "config/hub_cache", "config.yaml", "database/x", "datax", "bouncer_key"} + notOwned := []string{"", ".", "Config/Data/x", "config/Hub_Cache", "config/data", "config/data/x", "config/hub_cache", "config.yaml", "database/x", "datax", "bouncer_key"} for _, p := range notOwned { require.False(t, IsEngineOwnedPath(p), p) } @@ -672,10 +672,13 @@ func TestExtractTarGzOverlaysWithoutWipingAndSkipsEngineOwnedEntries(t *testing. dir := seedPersistentTree(t) svc := NewHubService(nil, nil, dir) archive := makeTarGz(t, map[string]string{ - "config/new.yaml": "new", - "crowdsec.db": "evil", - "data/x": "evil", - "hub_cache/y": "evil", + "config/new.yaml": "new", + "crowdsec.db": "evil", + "data/x": "evil", + "hub_cache/y": "evil", + "Data/crowdsec.db": "evil", + "HUB_CACHE/z": "evil", + "CrowdSec.DB-WAL": "evil", }) require.NoError(t, svc.extractTarGz(context.Background(), archive, dir)) @@ -684,6 +687,9 @@ func TestExtractTarGzOverlaysWithoutWipingAndSkipsEngineOwnedEntries(t *testing. require.Equal(t, "live-db", readFile(t, filepath.Join(dir, "crowdsec.db"))) require.NoFileExists(t, filepath.Join(dir, "data", "x")) require.NoFileExists(t, filepath.Join(dir, "hub_cache", "y")) + require.NoDirExists(t, filepath.Join(dir, "Data")) + require.NoDirExists(t, filepath.Join(dir, "HUB_CACHE")) + require.NoFileExists(t, filepath.Join(dir, "CrowdSec.DB-WAL")) } func TestRollbackFailureWrapsCause(t *testing.T) { diff --git a/backend/internal/crowdsec/companions.go b/backend/internal/crowdsec/companions.go new file mode 100644 index 000000000..ec4e4a20c --- /dev/null +++ b/backend/internal/crowdsec/companions.go @@ -0,0 +1,279 @@ +package crowdsec + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/util" +) + +// requiredCompanions are the files CrowdSec cannot start without (verified with `crowdsec -t` on +// v1.8.1) that an import clears with the rest of the configuration. The name "patterns" is a directory, +// the others are files. +var requiredCompanions = []string{"acquis.yaml", "patterns", "profiles.yaml", "simulation.yaml"} + +const ( + profilesCompanion = "profiles.yaml" + patternsCompanion = "patterns" + notificationsDir = "notifications" + keptNotificationNS = notificationsDir + "/" +) + +// ErrDuplicateNotifications is returned when restoring the notification files a kept profile needs +// leaves a notification name defined more than once. +var ErrDuplicateNotifications = errors.New("a restored notification file defines a name the archive already defines") + +// RestoreMissingCompanions copies the required companions that an import left out back from the +// snapshot taken before it. prefix is the folder below dataDir that holds the configuration ("config" +// for a nested install, "" for a flat one). Absence is decided with Lstat, so a link counts as present +// (it is kept across an import) and is never replaced or written through. When profiles.yaml was kept, +// the notification files it names that the new tree no longer defines are restored too. It returns the +// sorted names that were restored, notification files as "notifications/". +func RestoreMissingCompanions(snapshotDir, dataDir, prefix string) ([]string, error) { + liveBase := filepath.Join(filepath.Clean(dataDir), prefix) + snapBase := filepath.Join(filepath.Clean(snapshotDir), prefix) + var kept []string + for _, name := range requiredCompanions { + restored, err := restoreCompanion(snapBase, liveBase, name) + if err != nil { + return nil, err + } + if restored { + kept = append(kept, name) + } + } + if containsName(kept, profilesCompanion) { + files, err := restoreNeededNotifications(snapshotDir, dataDir, prefix) + if err != nil { + return nil, err + } + kept = append(kept, files...) + } + sort.Strings(kept) + return kept, nil +} + +func containsName(names []string, want string) bool { + for _, n := range names { + if n == want { + return true + } + } + return false +} + +// restoreCompanion copies one companion from the snapshot when nothing exists under that name in the +// live tree. It reports whether a copy was made. +func restoreCompanion(snapBase, liveBase, name string) (bool, error) { + live := filepath.Join(liveBase, name) + if _, err := os.Lstat(live); err == nil { + return false, nil // present in any form, including a link + } else if !errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("inspect %s: %w", name, withoutPath(err)) + } + snap := filepath.Join(snapBase, name) + st, err := os.Lstat(snap) + if treeEntryAbsent(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect snapshot %s: %w", name, withoutPath(err)) + } + wantDir := name == patternsCompanion + if st.Mode()&os.ModeSymlink != 0 || st.IsDir() != wantDir || (!wantDir && !st.Mode().IsRegular()) { + logger.Log().WithField("entry", name).Warn("crowdsec import: not restoring a companion that is a link or has the wrong type") + return false, nil + } + if wantDir { + if err = os.Mkdir(live, 0o750); err != nil { + return false, fmt.Errorf("create %s: %w", name, withoutPath(err)) + } + if err = copyTree(snap, live, nil); err != nil { + return false, fmt.Errorf("restore %s: %w", name, withoutPath(err)) + } + return true, nil + } + if err = copyFile(snap, live); err != nil { + return false, fmt.Errorf("restore %s: %w", name, withoutPath(err)) + } + return true, nil +} + +// restoreNeededNotifications copies back from the snapshot the notification files that define the names +// the kept profiles.yaml references and the new tree does not define. It never overwrites or writes +// through anything: a linked notifications folder, an existing destination path (even one holding the +// archive's own file of the same name), a linked snapshot file and a file outside the bounds all leave +// the name undefined for the caller's verification to refuse. It returns "notifications/" entries. +func restoreNeededNotifications(snapshotDir, dataDir, prefix string) ([]string, error) { + tree, err := InspectTree(dataDir) + if err != nil { + logger.Log().WithField("error", util.SanitizeForLog(err.Error())).Warn("crowdsec import: cannot read the new tree to restore notification files") + return nil, nil + } + missing := CheckProfileNotifications(tree) + if len(missing) == 0 { + return nil, nil + } + snap, err := InspectTree(snapshotDir) + if err != nil { + logger.Log().WithField("error", util.SanitizeForLog(err.Error())).Warn("crowdsec import: cannot read the snapshot to restore notification files") + return nil, nil + } + dir := path.Join(filepath.ToSlash(prefix), notificationsDir) + byFile, ok := definedNotificationsByFile(snap.Files, dir) + if !ok { + logger.Log().Warn("crowdsec import: a snapshot notification file cannot be read; not restoring notification files") + return nil, nil + } + needed := map[string]bool{} + for _, n := range missing { + needed[n] = true + } + files := make([]string, 0, len(byFile)) + for rel := range byFile { + files = append(files, rel) + } + sort.Strings(files) + + destDir := filepath.Join(filepath.Clean(dataDir), prefix, notificationsDir) + var restored []string + for _, rel := range files { + if !definesAny(byFile[rel], needed) { + continue + } + if st, lerr := os.Lstat(filepath.Join(filepath.Clean(snapshotDir), filepath.FromSlash(rel))); lerr != nil || st.Mode()&os.ModeSymlink != 0 { + logger.Log().WithField("file", util.SanitizeForLog(path.Base(rel))).Warn("crowdsec import: a snapshot notification file is a link or cannot be inspected; not restoring it") + continue + } + ready, err := prepareNotificationsDir(destDir) + if err != nil { + return nil, err + } + if !ready { + return restored, nil + } + wrote, err := writeNotificationFile(filepath.Join(destDir, path.Base(rel)), snap.Files[rel]) + if err != nil { + return nil, err + } + if !wrote { + continue + } + restored = append(restored, keptNotificationNS+path.Base(rel)) + for _, n := range byFile[rel] { + delete(needed, n) + } + } + return restored, nil +} + +func definesAny(names []string, wanted map[string]bool) bool { + for _, n := range names { + if wanted[n] { + return true + } + } + return false +} + +// prepareNotificationsDir makes sure dir is a plain directory, creating it when absent. It reports +// false (after logging) when dir is a link or not a directory. +func prepareNotificationsDir(dir string) (bool, error) { + st, err := os.Lstat(dir) + switch { + case errors.Is(err, fs.ErrNotExist): + if err = os.Mkdir(dir, 0o750); err != nil { + return false, fmt.Errorf("create notifications folder: %w", withoutPath(err)) + } + return true, nil + case err != nil: + return false, fmt.Errorf("inspect notifications folder: %w", withoutPath(err)) + case !st.IsDir(): + logger.Log().Warn("crowdsec import: the notifications folder is a link or not a directory; not restoring notification files") + return false, nil + } + return true, nil +} + +// writeNotificationFile creates dest with data, never replacing anything: an existing path of any kind +// is skipped and logged. It reports whether the file was written. +func writeNotificationFile(dest string, data []byte) (bool, error) { + if _, err := os.Lstat(dest); err == nil { + logger.Log().WithField("file", util.SanitizeForLog(filepath.Base(dest))).Warn("crowdsec import: a notification file of that name already exists; not restoring it") + return false, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("inspect notification file: %w", withoutPath(err)) + } + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) // #nosec G304 -- dest is a direct child of the notifications folder, name taken from a bounded directory listing + if errors.Is(err, fs.ErrExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("create notification file: %w", withoutPath(err)) + } + if _, err = f.Write(data); err != nil { + _ = f.Close() + return false, fmt.Errorf("write notification file: %w", withoutPath(err)) + } + if err = f.Close(); err != nil { + return false, fmt.Errorf("close notification file: %w", withoutPath(err)) + } + return true, nil +} + +// VerifyKeptProfile checks the live tree after RestoreMissingCompanions: when profiles.yaml was kept it +// must not name a notification the tree has no file for (*UndefinedNotificationsError), and restored +// notification files must not define a name twice (ErrDuplicateNotifications). It fails open when the +// tree cannot be read completely. +func VerifyKeptProfile(dataDir string, kept []string) error { + if !containsName(kept, profilesCompanion) { + return nil + } + tree, err := InspectTree(dataDir) + if err != nil { + logger.Log().WithField("error", util.SanitizeForLog(err.Error())).Warn("crowdsec import: kept profile check skipped") + return nil + } + if err := VerifyInspectedNotifications(tree); err != nil { + return err + } + if tree.Incomplete { + return nil + } + for _, k := range kept { + if strings.HasPrefix(k, keptNotificationNS) && hasDuplicateNotification(tree) { + return ErrDuplicateNotifications + } + } + return nil +} + +// hasDuplicateNotification reports whether any notification name is defined twice in the tree's +// notification files (in the folder CrowdSec reads for the tree's layout). +func hasDuplicateNotification(tree ArchiveInfo) bool { + dir := notificationsDir + if _, nested := tree.Files["config/config.yaml"]; nested { + dir = "config/" + notificationsDir + } + byFile, ok := definedNotificationsByFile(tree.Files, dir) + if !ok { + return false + } + seen := map[string]bool{} + for _, names := range byFile { + for _, n := range names { + if seen[n] { + return true + } + seen[n] = true + } + } + return false +} diff --git a/backend/internal/crowdsec/companions_test.go b/backend/internal/crowdsec/companions_test.go new file mode 100644 index 000000000..16d657c76 --- /dev/null +++ b/backend/internal/crowdsec/companions_test.go @@ -0,0 +1,616 @@ +package crowdsec + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + companionProfiles = "name: p\nfilters:\n - Alert.Remediation == true\ndecisions:\n - type: ban\n duration: 4h\nnotifications:\n - %s\n" +) + +func putFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + for rel, body := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o750)) + require.NoError(t, os.WriteFile(p, []byte(body), 0o600)) + } +} + +// companionTrees returns a snapshot and a live tree (both nested) holding the given files. +func companionTrees(t *testing.T, snap, live map[string]string) (snapDir, liveDir string) { + t.Helper() + snapDir, liveDir = t.TempDir(), t.TempDir() + putFiles(t, snapDir, snap) + putFiles(t, liveDir, live) + return snapDir, liveDir +} + +func TestRestoreMissingCompanionsKeepsWhatTheArchiveLacks(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{ + "config/acquis.yaml": "snap-acquis", + "config/profiles.yaml": "snap-profiles", + "config/simulation.yaml": "snap-sim", + "config/patterns/a": "pa", + "config/patterns/sub/b": "pb", + "config/unrelated.yaml": "x", + "config/notifications/n.y": "ignored", + }, + map[string]string{"config/config.yaml": plainConfig}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"acquis.yaml", "patterns", "profiles.yaml", "simulation.yaml"}, kept) + require.Equal(t, "snap-acquis", readFile(t, filepath.Join(liveDir, "config", "acquis.yaml"))) + require.Equal(t, "snap-profiles", readFile(t, filepath.Join(liveDir, "config", "profiles.yaml"))) + require.Equal(t, "snap-sim", readFile(t, filepath.Join(liveDir, "config", "simulation.yaml"))) + require.Equal(t, "pa", readFile(t, filepath.Join(liveDir, "config", "patterns", "a"))) + require.Equal(t, "pb", readFile(t, filepath.Join(liveDir, "config", "patterns", "sub", "b"))) + require.NoFileExists(t, filepath.Join(liveDir, "config", "unrelated.yaml")) +} + +func TestRestoreMissingCompanionsArchiveFilesWin(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{"config/acquis.yaml": "snap", "config/profiles.yaml": "snap", "config/patterns/a": "snap"}, + map[string]string{"config/acquis.yaml": "archive", "config/patterns/a": "archive", "config/patterns/extra": "archive"}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.Equal(t, "archive", readFile(t, filepath.Join(liveDir, "config", "acquis.yaml"))) + require.Equal(t, "archive", readFile(t, filepath.Join(liveDir, "config", "patterns", "a"))) + require.FileExists(t, filepath.Join(liveDir, "config", "patterns", "extra")) +} + +func TestRestoreMissingCompanionsAbsentInSnapshot(t *testing.T) { + t.Parallel() + for name, snap := range map[string]map[string]string{ + "nothing in the snapshot": {"config/config.yaml": plainConfig}, + "no config folder": {"other.yaml": "x"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, snap, map[string]string{"config/config.yaml": plainConfig}) + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + require.NoError(t, err) + require.Empty(t, kept) + }) + } +} + +func TestRestoreMissingCompanionsFlatPrefix(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{"acquis.yaml": "snap", "config/acquis.yaml": "nested snap"}, + map[string]string{"config/config.yaml": plainConfig}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "") + + require.NoError(t, err) + require.Equal(t, []string{"acquis.yaml"}, kept) + require.Equal(t, "snap", readFile(t, filepath.Join(liveDir, "acquis.yaml"))) + require.NoFileExists(t, filepath.Join(liveDir, "config", "acquis.yaml")) +} + +func TestRestoreMissingCompanionsLinkedCompanionsCountAsPresent(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{"config/profiles.yaml": "snap", "config/patterns/a": "snap", "config/acquis.yaml": "snap"}, + map[string]string{"config/config.yaml": plainConfig}) + outside := t.TempDir() + putFiles(t, outside, map[string]string{"profiles.yaml": "outside", "patterns/a": "outside"}) + cfg := filepath.Join(liveDir, "config") + require.NoError(t, os.Symlink(filepath.Join(outside, "patterns"), filepath.Join(cfg, "patterns"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "profiles.yaml"), filepath.Join(cfg, "profiles.yaml"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "missing-target"), filepath.Join(cfg, "acquis.yaml"))) // dangling + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Empty(t, kept) + require.Equal(t, "outside", readFile(t, filepath.Join(outside, "profiles.yaml"))) + require.Equal(t, "outside", readFile(t, filepath.Join(outside, "patterns", "a"))) + require.NoFileExists(t, filepath.Join(outside, "missing-target")) + for _, name := range []string{"patterns", "profiles.yaml", "acquis.yaml"} { + st, lerr := os.Lstat(filepath.Join(cfg, name)) + require.NoError(t, lerr) + require.NotZero(t, st.Mode()&os.ModeSymlink, name) + } +} + +func TestRestoreMissingCompanionsSkipsLinkedOrMistypedSnapshotEntries(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{"config/simulation.yaml/inside": "dir where a file belongs", "config/patterns": "file where a dir belongs"}, + map[string]string{"config/config.yaml": plainConfig}) + require.NoError(t, os.Symlink("/etc/hostname", filepath.Join(snapDir, "config", "acquis.yaml"))) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Empty(t, kept) + for _, name := range []string{"acquis.yaml", "simulation.yaml", "patterns"} { + _, lerr := os.Lstat(filepath.Join(liveDir, "config", name)) + require.ErrorIs(t, lerr, os.ErrNotExist, name) + } +} + +func TestRestoreMissingCompanionsReportsUnreadableTree(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + t.Parallel() + snapDir, liveDir := companionTrees(t, map[string]string{"config/acquis.yaml": "x"}, map[string]string{"config/config.yaml": plainConfig}) + require.NoError(t, os.Chmod(filepath.Join(liveDir, "config"), 0o000)) + t.Cleanup(func() { _ = os.Chmod(filepath.Join(liveDir, "config"), 0o750) }) //nolint:gosec // G302: the test tree must be removable + + _, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.Error(t, err) + require.NotContains(t, err.Error(), liveDir) +} + +func TestRestoreMissingCompanionsCopyFailureIsReported(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, map[string]string{"config/patterns/a": "x", "config/profiles.yaml": "x"}, nil) + // The live folder is missing, so creating the companions below it fails. + _, err := RestoreMissingCompanions(snapDir, liveDir, "config") + require.Error(t, err) +} + +// keptProfileTrees is a nested snapshot with a profile naming "alpha", defined by a notification file. +func keptProfileTrees(t *testing.T, archive map[string]string) (snapDir, liveDir string) { + t.Helper() + snap := map[string]string{ + "config/config.yaml": plainConfig, + "config/profiles.yaml": strings.Replace(companionProfiles, "%s", "alpha", 1), + "config/notifications/alpha.yaml": notif("alpha"), + "config/notifications/beta.yml": notif("beta"), + } + live := map[string]string{"config/config.yaml": plainConfig} + for k, v := range archive { + live[k] = v + } + return companionTrees(t, snap, live) +} + +func TestRestoreMissingCompanionsRestoresNeededNotificationFiles(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, nil) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"notifications/alpha.yaml", "profiles.yaml"}, kept) + require.Equal(t, notif("alpha"), readFile(t, filepath.Join(liveDir, "config", "notifications", "alpha.yaml"))) + require.NoFileExists(t, filepath.Join(liveDir, "config", "notifications", "beta.yml"), "files the profile does not need stay out") + require.NoError(t, VerifyKeptProfile(liveDir, kept)) +} + +func TestRestoreMissingCompanionsNeededNotificationAlreadyDefined(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, map[string]string{"config/notifications/mine.yaml": notif("alpha")}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.NoError(t, VerifyKeptProfile(liveDir, kept)) +} + +func TestRestoreMissingCompanionsNotificationFlatLayout(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{"profiles.yaml": strings.Replace(companionProfiles, "%s", "alpha", 1), "notifications/alpha.yaml": notif("alpha")}, + map[string]string{"config.yaml": plainConfig}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "") + + require.NoError(t, err) + require.Equal(t, []string{"notifications/alpha.yaml", "profiles.yaml"}, kept) + require.NoError(t, VerifyKeptProfile(liveDir, kept)) +} + +func TestRestoreMissingCompanionsNotificationFolderIsLinked(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, nil) + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(liveDir, "config", "notifications"))) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + entries, rerr := os.ReadDir(outside) + require.NoError(t, rerr) + require.Empty(t, entries, "nothing may be written through the link") + var undefined *UndefinedNotificationsError + require.ErrorAs(t, VerifyKeptProfile(liveDir, kept), &undefined) + require.Equal(t, []string{"alpha"}, undefined.Names) +} + +func TestRestoreMissingCompanionsNameCollisionKeepsArchiveFile(t *testing.T) { + t.Parallel() + // The archive's own alpha.yaml defines another name, so the needed file cannot be restored under it. + snapDir, liveDir := keptProfileTrees(t, map[string]string{"config/notifications/alpha.yaml": notif("other")}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.Equal(t, notif("other"), readFile(t, filepath.Join(liveDir, "config", "notifications", "alpha.yaml"))) + var undefined *UndefinedNotificationsError + require.ErrorAs(t, VerifyKeptProfile(liveDir, kept), &undefined) + require.Equal(t, []string{"alpha"}, undefined.Names) +} + +func TestRestoreMissingCompanionsDestinationLinkIsNeverReplaced(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, nil) + outside := filepath.Join(t.TempDir(), "target.yaml") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(liveDir, "config", "notifications"), 0o750)) + require.NoError(t, os.Symlink(outside, filepath.Join(liveDir, "config", "notifications", "alpha.yaml"))) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.Equal(t, "outside", readFile(t, outside)) + var undefined *UndefinedNotificationsError + require.ErrorAs(t, VerifyKeptProfile(liveDir, kept), &undefined) +} + +func TestRestoreMissingCompanionsSkipsLinkedSnapshotNotificationFile(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, nil) + require.NoError(t, os.Remove(filepath.Join(snapDir, "config", "notifications", "alpha.yaml"))) + outside := filepath.Join(t.TempDir(), "alpha.yaml") + require.NoError(t, os.WriteFile(outside, []byte(notif("alpha")), 0o600)) + require.NoError(t, os.Symlink(outside, filepath.Join(snapDir, "config", "notifications", "alpha.yaml"))) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.NoDirExists(t, filepath.Join(liveDir, "config", "notifications")) +} + +func TestRestoreMissingCompanionsDuplicateAfterRestore(t *testing.T) { + t.Parallel() + // The snapshot file that defines alpha also defines beta, which the archive already defines. + snapDir, liveDir := keptProfileTrees(t, map[string]string{"config/notifications/beta-live.yaml": notif("beta")}) + putFiles(t, snapDir, map[string]string{"config/notifications/alpha.yaml": notif("alpha") + "---\n" + notif("beta")}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"notifications/alpha.yaml", "profiles.yaml"}, kept) + require.ErrorIs(t, VerifyKeptProfile(liveDir, kept), ErrDuplicateNotifications) +} + +func TestRestoreMissingCompanionsNoSnapshotFileDefinesTheName(t *testing.T) { + t.Parallel() + snapDir, liveDir := companionTrees(t, + map[string]string{"config/profiles.yaml": strings.Replace(companionProfiles, "%s", "ghost", 1)}, + map[string]string{"config/config.yaml": plainConfig}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + var undefined *UndefinedNotificationsError + require.ErrorAs(t, VerifyKeptProfile(liveDir, kept), &undefined) + require.Equal(t, []string{"ghost"}, undefined.Names) +} + +func TestRestoreMissingCompanionsUnparsableSnapshotNotificationIsSkipped(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, nil) + putFiles(t, snapDir, map[string]string{"config/notifications/broken.yaml": "name: [unclosed\n"}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.NoDirExists(t, filepath.Join(liveDir, "config", "notifications")) +} + +func TestVerifyKeptProfileOnlyChecksAKeptProfile(t *testing.T) { + t.Parallel() + _, liveDir := companionTrees(t, nil, map[string]string{ + "config/config.yaml": plainConfig, + "config/profiles.yaml": strings.Replace(companionProfiles, "%s", "ghost", 1), + }) + require.NoError(t, VerifyKeptProfile(liveDir, nil)) + require.NoError(t, VerifyKeptProfile(liveDir, []string{"acquis.yaml"})) + require.Error(t, VerifyKeptProfile(liveDir, []string{"profiles.yaml"})) +} + +func TestVerifyKeptProfileFailsOpenWhenTheTreeCannotBeRead(t *testing.T) { + t.Parallel() + _, liveDir := companionTrees(t, nil, map[string]string{"config/config.yaml": plainConfig}) + require.NoError(t, os.WriteFile(filepath.Join(liveDir, "config", "profiles.yaml"), []byte(strings.Repeat("#", int(maxInspectEntryBytes)+1)), 0o600)) + require.NoError(t, VerifyKeptProfile(liveDir, []string{"profiles.yaml"}), "an oversized entry makes the inspection incomplete") + if os.Geteuid() != 0 { + require.NoError(t, os.Chmod(filepath.Join(liveDir, "config", "profiles.yaml"), 0o000)) + require.NoError(t, VerifyKeptProfile(liveDir, []string{"profiles.yaml"}), "an unreadable entry fails open") + } +} + +func TestHasDuplicateNotificationIgnoresUnparsableFiles(t *testing.T) { + t.Parallel() + require.False(t, hasDuplicateNotification(ArchiveInfo{Files: map[string][]byte{"notifications/a.yaml": []byte("name: [x\n")}})) +} + +func TestInspectTree(t *testing.T) { + t.Parallel() + dir := t.TempDir() + putFiles(t, dir, map[string]string{ + "config/config.yaml": plainConfig, + "config/profiles.yaml": "p", + "config/notifications/a.yaml": notif("a"), + "config/notifications/b.yml": notif("b"), + "config/notifications/ignored.txt": "x", + "config/notifications/sub/deeper.yml": "x", + "config/acquis.yaml": "not wanted", + "config.yaml": plainConfig, + "notifications/top.yaml": notif("top"), + }) + + info, err := InspectTree(dir) + + require.NoError(t, err) + require.False(t, info.Incomplete) + names := make([]string, 0, len(info.Files)) + for n := range info.Files { + names = append(names, n) + } + require.ElementsMatch(t, []string{"config.yaml", "notifications/top.yaml", "config/config.yaml", "config/profiles.yaml", "config/notifications/a.yaml", "config/notifications/b.yml"}, names) +} + +func TestInspectTreeIncompleteCases(t *testing.T) { + t.Parallel() + huge := strings.Repeat("#", int(maxInspectEntryBytes)+1) + cases := map[string]func(t *testing.T, dir string){ + "oversized profile": func(t *testing.T, dir string) { putFiles(t, dir, map[string]string{"config/profiles.yaml": huge}) }, + "directory named like the profile": func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "config", "profiles.yaml"), 0o750)) + }, + "notifications is a file": func(t *testing.T, dir string) { putFiles(t, dir, map[string]string{"config/notifications": "x"}) }, + "too many notification files": func(t *testing.T, dir string) { + for i := 0; i <= maxInspectEntries; i++ { + putFiles(t, dir, map[string]string{"config/notifications/" + strings.Repeat("n", 1) + string(rune('a'+i%26)) + strings.Repeat("x", i/26) + ".yaml": "name: x\n"}) + } + }, + "total size": func(t *testing.T, dir string) { + big := strings.Repeat("#", int(maxInspectEntryBytes)) + for i := 0; i < 9; i++ { + putFiles(t, dir, map[string]string{"config/notifications/big" + string(rune('a'+i)) + ".yaml": big}) + } + }, + } + for name, setup := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + setup(t, dir) + info, err := InspectTree(dir) + require.NoError(t, err) + require.True(t, info.Incomplete) + }) + } +} + +func TestInspectTreeErrors(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + t.Parallel() + for name, mode := range map[string]os.FileMode{"unreadable profile": 0o000} { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + putFiles(t, dir, map[string]string{"profiles.yaml": "p"}) + require.NoError(t, os.Chmod(filepath.Join(dir, "profiles.yaml"), mode)) + _, err := InspectTree(dir) + require.Error(t, err) + require.NotContains(t, err.Error(), dir) + }) + } + t.Run("unreadable notifications folder", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + putFiles(t, dir, map[string]string{"notifications/a.yaml": "x"}) + require.NoError(t, os.Chmod(filepath.Join(dir, "notifications"), 0o000)) + t.Cleanup(func() { _ = os.Chmod(filepath.Join(dir, "notifications"), 0o750) }) //nolint:gosec // G302: the test tree must be removable + _, err := InspectTree(dir) + require.Error(t, err) + }) + t.Run("unreadable config folder", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + putFiles(t, dir, map[string]string{"config/profiles.yaml": "p"}) + require.NoError(t, os.Chmod(filepath.Join(dir, "config"), 0o000)) + t.Cleanup(func() { _ = os.Chmod(filepath.Join(dir, "config"), 0o750) }) //nolint:gosec // G302: the test tree must be removable + _, err := InspectTree(dir) + require.Error(t, err) + }) +} + +func TestUndefinedNotificationsErrorNameList(t *testing.T) { + t.Parallel() + e := &UndefinedNotificationsError{Names: []string{"a", "b"}} + require.Equal(t, "a, b", e.NameList()) + require.Equal(t, "profiles.yaml names notifications that have no matching file: a, b", e.Error()) +} + +func TestRestoreMissingCompanionsNotificationsPathIsAFile(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, map[string]string{"config/notifications": "a file"}) + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + require.Equal(t, "a file", readFile(t, filepath.Join(liveDir, "config", "notifications"))) +} + +func TestRestoreMissingCompanionsCannotCreateNotificationsFolder(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, nil) + cfg := filepath.Join(liveDir, "config") + require.NoError(t, os.WriteFile(filepath.Join(cfg, "acquis.yaml"), []byte("a"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(cfg, "simulation.yaml"), []byte("s"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, "patterns"), 0o750)) + require.NoError(t, os.Chmod(cfg, 0o500)) //nolint:gosec // G302: read-only folder under test + t.Cleanup(func() { _ = os.Chmod(cfg, 0o750) }) //nolint:gosec // G302: the test tree must be removable + + _, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.Error(t, err) +} + +func TestInspectTreeFollowsLinksLikeCrowdSecDoes(t *testing.T) { + t.Parallel() + dir, outside := t.TempDir(), t.TempDir() + putFiles(t, outside, map[string]string{"notifications/a.yaml": notif("a"), "profiles.yaml": "linked profiles"}) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "config"), 0o750)) + require.NoError(t, os.Symlink(filepath.Join(outside, "notifications"), filepath.Join(dir, "config", "notifications"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "profiles.yaml"), filepath.Join(dir, "config", "profiles.yaml"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "gone.yaml"), filepath.Join(dir, "config", "config.yaml"))) // dangling + + info, err := InspectTree(dir) + + require.NoError(t, err) + require.False(t, info.Incomplete) + require.Equal(t, notif("a"), string(info.Files["config/notifications/a.yaml"])) + require.Equal(t, "linked profiles", string(info.Files["config/profiles.yaml"])) + require.NotContains(t, info.Files, "config/config.yaml") +} + +func TestRestoreMissingCompanionsUnreadableNotificationFoldersFailOpen(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + t.Parallel() + for name, where := range map[string]string{"live": "live", "snapshot": "snapshot"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + snapDir, liveDir := keptProfileTrees(t, map[string]string{"config/notifications/mine.yaml": notif("mine")}) + locked := filepath.Join(liveDir, "config", "notifications") + if where == "snapshot" { + locked = filepath.Join(snapDir, "config", "notifications") + require.NoError(t, os.Remove(filepath.Join(liveDir, "config", "notifications", "mine.yaml"))) + require.NoError(t, os.Remove(filepath.Join(liveDir, "config", "notifications"))) + } + require.NoError(t, os.Chmod(locked, 0o000)) + t.Cleanup(func() { _ = os.Chmod(locked, 0o750) }) //nolint:gosec // G302: the test tree must be removable + + kept, err := RestoreMissingCompanions(snapDir, liveDir, "config") + + require.NoError(t, err) + require.Equal(t, []string{"profiles.yaml"}, kept) + }) + } +} + +func TestPrepareNotificationsDir(t *testing.T) { + t.Parallel() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "file"), []byte("x"), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(root, "dir"), 0o750)) + + ready, err := prepareNotificationsDir(filepath.Join(root, "dir")) + require.NoError(t, err) + require.True(t, ready, "an existing plain folder is usable") + + ready, err = prepareNotificationsDir(filepath.Join(root, "new")) + require.NoError(t, err) + require.True(t, ready) + require.DirExists(t, filepath.Join(root, "new")) + + ready, err = prepareNotificationsDir(filepath.Join(root, "file")) + require.NoError(t, err) + require.False(t, ready, "a file in the way is refused without an error") + + // A parent that is a file makes the inspection itself fail (not "does not exist"). + _, err = prepareNotificationsDir(filepath.Join(root, "file", "notifications")) + require.Error(t, err) + + // A missing parent makes the creation fail. + _, err = prepareNotificationsDir(filepath.Join(root, "missing", "notifications")) + require.Error(t, err) +} + +func TestWriteNotificationFile(t *testing.T) { + t.Parallel() + root := t.TempDir() + dest := filepath.Join(root, "http.yaml") + + wrote, err := writeNotificationFile(dest, []byte("name: a\n")) + require.NoError(t, err) + require.True(t, wrote) + require.Equal(t, "name: a\n", readFile(t, dest)) + st, err := os.Stat(dest) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), st.Mode().Perm()) + + wrote, err = writeNotificationFile(dest, []byte("name: other\n")) + require.NoError(t, err) + require.False(t, wrote, "an existing file is never replaced") + require.Equal(t, "name: a\n", readFile(t, dest)) + + target := filepath.Join(root, "target.yaml") + require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600)) + link := filepath.Join(root, "link.yaml") + require.NoError(t, os.Symlink(target, link)) + wrote, err = writeNotificationFile(link, []byte("overwritten")) + require.NoError(t, err) + require.False(t, wrote, "a link is never written through") + require.Equal(t, "keep", readFile(t, target)) + + dangling := filepath.Join(root, "dangling.yaml") + require.NoError(t, os.Symlink(filepath.Join(root, "absent.yaml"), dangling)) + wrote, err = writeNotificationFile(dangling, []byte("created")) + require.NoError(t, err) + require.False(t, wrote, "a dangling link is never written through") + require.NoFileExists(t, filepath.Join(root, "absent.yaml")) + + // A parent that is a file makes the inspection fail with something other than "does not exist". + require.NoError(t, os.WriteFile(filepath.Join(root, "file"), []byte("x"), 0o600)) + _, err = writeNotificationFile(filepath.Join(root, "file", "x.yaml"), []byte("a")) + require.Error(t, err) + + // A missing folder makes the creation fail. + _, err = writeNotificationFile(filepath.Join(root, "missing", "x.yaml"), []byte("a")) + require.Error(t, err) +} + +func TestWriteNotificationFileCannotCreateInReadOnlyFolder(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o500)) //nolint:gosec // G302: read-only folder under test + t.Cleanup(func() { _ = os.Chmod(dir, 0o750) }) //nolint:gosec // G302: the test tree must be removable + + wrote, err := writeNotificationFile(filepath.Join(dir, "x.yaml"), []byte("a")) + + require.Error(t, err) + require.False(t, wrote) +} diff --git a/backend/internal/crowdsec/hubstate.go b/backend/internal/crowdsec/hubstate.go index efbeb12ed..1a9884512 100644 --- a/backend/internal/crowdsec/hubstate.go +++ b/backend/internal/crowdsec/hubstate.go @@ -9,9 +9,9 @@ import ( // IsHubPath reports whether rel (a slash- or OS-separated path relative to DataDir) lies in the // installed hub tree: hub/ at the top level or config/hub/. The tree is a regenerable cache managed by // cscli, and installed items are links into it, so it is treated as server-local state. Matching is -// case-sensitive and a nested directory merely named "hub" stays configuration. +// case-insensitive and a nested directory merely named "hub" stays configuration. func IsHubPath(rel string) bool { - slash := filepath.ToSlash(filepath.Clean(rel)) + slash := strings.ToLower(filepath.ToSlash(filepath.Clean(rel))) if slash == "." || slash == "" { return false } diff --git a/backend/internal/crowdsec/hubstate_test.go b/backend/internal/crowdsec/hubstate_test.go index 38064d932..12a0e1fe9 100644 --- a/backend/internal/crowdsec/hubstate_test.go +++ b/backend/internal/crowdsec/hubstate_test.go @@ -17,8 +17,12 @@ func TestIsHubPath(t *testing.T) { "config/hub": true, "config/hub/x.yaml": true, "./config/hub/x.yaml": true, - "Hub/x": false, - "config/Hub/x": false, + "Hub/x": true, + "HUB": true, + "config/Hub/x": true, + "Config/HUB/y": true, + "config/Hubs/x": false, + "nested/Hub/x": false, "config/hubs/x": false, "hubs/x": false, "nested/hub/x": false, @@ -59,7 +63,8 @@ func TestIsEngineOwnedPathPidFile(t *testing.T) { t.Parallel() require.True(t, IsEngineOwnedPath("crowdsec.pid")) require.True(t, IsEngineOwnedPath("./crowdsec.pid")) - for _, rel := range []string{"a/crowdsec.pid", "config/crowdsec.pid", "crowdsec.pid.bak", "crowdsec.pidx", "my-crowdsec.pid"} { + require.True(t, IsEngineOwnedPath("CrowdSec.PID")) + for _, rel := range []string{"a/crowdsec.pid", "config/crowdsec.pid", "crowdsec.pid.bak", "crowdsec.pidx", "my-crowdsec.pid", "A/CrowdSec.PID"} { require.False(t, IsEngineOwnedPath(rel), rel) } } diff --git a/backend/internal/crowdsec/layout.go b/backend/internal/crowdsec/layout.go new file mode 100644 index 000000000..c4d8f8373 --- /dev/null +++ b/backend/internal/crowdsec/layout.go @@ -0,0 +1,75 @@ +package crowdsec + +import ( + "errors" + "os" + "path/filepath" + "syscall" + + "github.com/Wikid82/charon/backend/internal/logger" +) + +// Layout is where the live CrowdSec tree keeps its configuration. +type Layout int + +const ( + // LayoutFlat is a tree with no configuration folder yet (fresh or never configured); both archive + // layouts are acceptable. + LayoutFlat Layout = iota + // LayoutNested is a tree whose configuration lives in /config, the folder CrowdSec reads + // (/etc/crowdsec is a link to it). + LayoutNested +) + +// ErrArchiveLayout is returned when an archive keeps its configuration where a nested installation +// does not read it. +var ErrArchiveLayout = errors.New("archive layout does not match the live configuration folder") + +// DetectLayout reports the layout of the live tree below dataDir. It is nested when config/config.yaml +// exists or config/ holds at least one entry (a populated folder without config.yaml is still the folder +// CrowdSec reads). config/ is resolved by following links, because that is what /etc/crowdsec does. +// Edge cases: +// - config/ with only hub/ or credentials: nested +// - empty config/ directory, a regular file named config, or no config (also below a file): flat +// - config/ unreadable, or a dangling config link: nested (fails safe: flat archives are refused and +// nothing changes) +// - config symlink to a populated directory: nested +// +// Callers hold the data lock; the result is never cached. +func DetectLayout(dataDir string) Layout { + configDir := filepath.Join(filepath.Clean(dataDir), "config") + if _, err := os.Stat(filepath.Join(configDir, "config.yaml")); err == nil { + return LayoutNested + } + st, err := os.Stat(configDir) + if err != nil { + if _, lerr := os.Lstat(configDir); lerr == nil { + logger.Log().Warn("crowdsec config folder is a dangling link; treating the layout as nested") + return LayoutNested + } else if errors.Is(lerr, os.ErrNotExist) || errors.Is(lerr, syscall.ENOTDIR) { + return LayoutFlat + } + logger.Log().Warn("crowdsec config folder cannot be inspected; treating the layout as nested") + return LayoutNested + } + if !st.IsDir() { + return LayoutFlat + } + entries, err := os.ReadDir(configDir) + if err != nil { + logger.Log().Warn("crowdsec config folder cannot be read; treating the layout as nested") + return LayoutNested + } + if len(entries) == 0 { + return LayoutFlat + } + return LayoutNested +} + +// CheckImportLayout refuses an archive without config/config.yaml for a nested live tree. +func CheckImportLayout(live Layout, a ArchiveInfo) error { + if live == LayoutNested && !a.HasNestedConfig { + return ErrArchiveLayout + } + return nil +} diff --git a/backend/internal/crowdsec/layout_test.go b/backend/internal/crowdsec/layout_test.go new file mode 100644 index 000000000..a797e2947 --- /dev/null +++ b/backend/internal/crowdsec/layout_test.go @@ -0,0 +1,143 @@ +package crowdsec + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDetectLayout(t *testing.T) { + t.Parallel() + write := func(t *testing.T, p string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o750)) + require.NoError(t, os.WriteFile(p, []byte("x: 1\n"), 0o600)) + } + cases := []struct { + name string + setup func(t *testing.T, dir string) + want Layout + }{ + {"nothing at all", func(*testing.T, string) {}, LayoutFlat}, + {"config yaml in config folder", func(t *testing.T, d string) { write(t, filepath.Join(d, "config", "config.yaml")) }, LayoutNested}, + {"config folder with only hub", func(t *testing.T, d string) { + require.NoError(t, os.MkdirAll(filepath.Join(d, "config", "hub"), 0o750)) + }, LayoutNested}, + {"config folder with only credentials", func(t *testing.T, d string) { write(t, filepath.Join(d, "config", "local_api_credentials.yaml")) }, LayoutNested}, + {"empty config folder", func(t *testing.T, d string) { require.NoError(t, os.MkdirAll(filepath.Join(d, "config"), 0o750)) }, LayoutFlat}, + {"config is a regular file", func(t *testing.T, d string) { write(t, filepath.Join(d, "config")) }, LayoutFlat}, + {"top level config yaml only", func(t *testing.T, d string) { write(t, filepath.Join(d, "config.yaml")) }, LayoutFlat}, + {"both layouts", func(t *testing.T, d string) { + write(t, filepath.Join(d, "config.yaml")) + write(t, filepath.Join(d, "config", "config.yaml")) + }, LayoutNested}, + {"hybrid tree of a fresh container", func(t *testing.T, d string) { + write(t, filepath.Join(d, "config.yaml")) + write(t, filepath.Join(d, "acquis.yaml")) + write(t, filepath.Join(d, "config", "local_api_credentials.yaml")) + }, LayoutNested}, + {"link to a populated folder", func(t *testing.T, d string) { + target := filepath.Join(t.TempDir(), "real") + write(t, filepath.Join(target, "config.yaml")) + require.NoError(t, os.Symlink(target, filepath.Join(d, "config"))) + }, LayoutNested}, + {"link to an empty folder", func(t *testing.T, d string) { + target := filepath.Join(t.TempDir(), "real") + require.NoError(t, os.MkdirAll(target, 0o750)) + require.NoError(t, os.Symlink(target, filepath.Join(d, "config"))) + }, LayoutFlat}, + {"dangling link", func(t *testing.T, d string) { + require.NoError(t, os.Symlink(filepath.Join(d, "missing"), filepath.Join(d, "config"))) + }, LayoutNested}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tc.setup(t, dir) + require.Equal(t, tc.want, DetectLayout(dir)) + }) + } +} + +func TestDetectLayoutUnreadableConfigFolderIsNested(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + dir := t.TempDir() + configDir := filepath.Join(dir, "config") + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "hub"), 0o750)) + require.NoError(t, os.Chmod(configDir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(configDir, 0o750) }) //nolint:gosec // G302: a directory needs the execute bit so the test tree can be removed + require.Equal(t, LayoutNested, DetectLayout(dir)) +} + +func TestCheckImportLayout(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + live Layout + archive ArchiveInfo + refused bool + }{ + {"nested live, nested archive", LayoutNested, ArchiveInfo{HasNestedConfig: true}, false}, + {"nested live, flat archive", LayoutNested, ArchiveInfo{}, true}, + {"flat live, nested archive", LayoutFlat, ArchiveInfo{HasNestedConfig: true}, false}, + {"flat live, neither", LayoutFlat, ArchiveInfo{}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := CheckImportLayout(tc.live, tc.archive) + if tc.refused { + require.ErrorIs(t, err, ErrArchiveLayout) + return + } + require.NoError(t, err) + }) + } +} + +func TestInspectArchiveLayoutFlags(t *testing.T) { + t.Parallel() + huge := strings.Repeat("#", int(maxInspectEntryBytes)+1) + for _, tc := range []struct { + name string + files map[string]string + nested bool + }{ + {"nested", map[string]string{"config/config.yaml": plainConfig}, true}, + {"flat", map[string]string{"config.yaml": plainConfig}, false}, + {"both", map[string]string{"config.yaml": plainConfig, "config/config.yaml": plainConfig}, true}, + {"normalised spellings", map[string]string{"./config//config.yaml": plainConfig, "x/../config.yaml": plainConfig}, true}, + {"deeper copies do not count", map[string]string{"parsers/config.yaml": plainConfig, "config/sub/config.yaml": plainConfig}, false}, + {"oversized entries still set the flags", map[string]string{"config/config.yaml": huge, "config.yaml": huge}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + info, err := InspectArchive(writeArchive(t, tc.files)) + require.NoError(t, err) + require.Equal(t, tc.nested, info.HasNestedConfig) + }) + } +} + +func TestDetectLayoutUninspectableTreeIsNested(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not restrict root") + } + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "config"), 0o750)) + require.NoError(t, os.Chmod(dir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(dir, 0o750) }) //nolint:gosec // G302: a directory needs the execute bit so the test tree can be removed + require.Equal(t, LayoutNested, DetectLayout(dir)) +} + +func TestDetectLayoutBelowAFileIsFlat(t *testing.T) { + t.Parallel() + blocker := filepath.Join(t.TempDir(), "blocker") + require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o600)) + require.Equal(t, LayoutFlat, DetectLayout(filepath.Join(blocker, "crowdsec"))) +} diff --git a/backend/internal/crowdsec/notifications_check.go b/backend/internal/crowdsec/notifications_check.go index 52249b2fa..404d425e4 100644 --- a/backend/internal/crowdsec/notifications_check.go +++ b/backend/internal/crowdsec/notifications_check.go @@ -30,7 +30,8 @@ type UndefinedNotificationsError struct { Names []string } -func (e *UndefinedNotificationsError) Error() string { +// NameList spells out the undefined names, bounded in count and length and safe to log or show. +func (e *UndefinedNotificationsError) NameList() string { listed := e.Names more := 0 if len(listed) > maxListedNotifications { @@ -45,13 +46,17 @@ func (e *UndefinedNotificationsError) Error() string { } safe[i] = n } - msg := "profiles.yaml names notifications that have no matching file: " + strings.Join(safe, ", ") + msg := strings.Join(safe, ", ") if more > 0 { msg += fmt.Sprintf(", and %d more", more) } return msg } +func (e *UndefinedNotificationsError) Error() string { + return "profiles.yaml names notifications that have no matching file: " + e.NameList() +} + // VerifyInspectedNotifications returns an *UndefinedNotificationsError when the inspected archive's // profiles name notifications that none of its notification files defines. Cases the archive cannot // settle (see CheckProfileNotifications) pass. @@ -190,7 +195,24 @@ func referencedNotifications(profiles []byte) (map[string]bool, bool) { // definedNotifications collects name: from every document of every notification file CrowdSec loads // from dir. It reports false when any such file does not parse. func definedNotifications(files map[string][]byte, dir string) (map[string]bool, bool) { + byFile, ok := definedNotificationsByFile(files, dir) + if !ok { + return nil, false + } defined := map[string]bool{} + for _, names := range byFile { + for _, n := range names { + defined[n] = true + } + } + return defined, true +} + +// definedNotificationsByFile maps every notification file in dir to the names its documents define (one +// entry per document, so a name repeated in a file is repeated here). It reports false when any such +// file does not parse. +func definedNotificationsByFile(files map[string][]byte, dir string) (map[string][]string, bool) { + byFile := map[string][]string{} for rel, data := range files { if path.Dir(rel) != dir { continue @@ -199,13 +221,15 @@ func definedNotifications(files map[string][]byte, dir string) (map[string]bool, if err != nil { return nil, false } + names := []string{} for _, doc := range docs { if n := mappingValue(documentMapping(doc), "name"); n != nil && n.Kind == yaml.ScalarNode && n.Tag == "!!str" { - defined[n.Value] = true + names = append(names, n.Value) } } + byFile[rel] = names } - return defined, true + return byFile, true } func resolveAlias(n *yaml.Node) *yaml.Node { diff --git a/docs/features/crowdsec.md b/docs/features/crowdsec.md index b504a6299..69edde128 100644 --- a/docs/features/crowdsec.md +++ b/docs/features/crowdsec.md @@ -69,6 +69,10 @@ Hub presets are applied on the server too, with the same safety net: before anyt - An exported CrowdSec configuration is meant for moving settings, not for sharing. CrowdSec's account and connection details are not included, but other saved settings (for example notification and database settings) still can be, so treat the file as private. When you import one, your running system keeps its own. Its installed hub items and runtime state stay as they are, so an import cannot remove or change them. An exported configuration no longer contains the downloaded hub data; CrowdSec downloads it again when needed. CrowdSec re-creates its local connection details on its own. If you move to a new host and use community blocklists or the CrowdSec Console, re-enable console enrollment there. - An exported configuration also lists which hub items (collections, parsers, scenarios and so on) were installed on the system it came from. After an import, if some of those items are not installed on this system, the CrowdSec page offers an **Install missing hub items** button. It downloads and installs them from the official CrowdSec hub. This needs an internet connection and can take several minutes. If the hub cannot be reached, nothing is installed and you can simply try again later. If some items were not reached in time, an **Install remaining** button lets you retry just those. Items that are already installed are never removed or changed by an import. - Before changing anything, an import checks that every notification name used by your alert profiles exists in the imported configuration. If any are missing, the import is refused and the message lists the missing names. +- If your CrowdSec already keeps its settings in the `config/` folder (every normal Charon install does), an import must keep them there too. An archive with its settings at the top level is refused with a clear message and nothing changes. Export a configuration from this page, or put the files in a `config/` folder inside the archive, then try again. +- CrowdSec cannot start without `profiles.yaml`, `simulation.yaml`, `acquis.yaml` and a `patterns/` folder. If your archive leaves any of them out, Charon keeps the current ones and the import card lists which were kept. If a kept `profiles.yaml` names notifications, those notification files are kept too. If they still cannot be matched, the import is refused and nothing changes. +- Limits: a file or folder is kept only when it is missing entirely. One that is present but incomplete (for example a `patterns/` folder with a single file) is used as it is and is not repaired, so CrowdSec may fail to start. The `acquis.d/` folder is not checked. After a restart, Charon recreates `acquis.yaml` if it is missing, but not the other three. +- Installing missing hub items now reports an error if CrowdSec's list of installed items cannot be read, and says what was already installed. Trying again is safe. Configuration changes also stop waiting if you cancel or close the request. - The export and import messages are translated into your language. - The CrowdSec import accepts only `.tar.gz` archives and checks that the main configuration file is valid YAML before applying anything. If it isn't, Charon tells you what's wrong, for example `config.yaml is not valid YAML` or `only .tar.gz archives are supported`. - Backups are shown by name and are stored next to the CrowdSec data folder (for example `/app/data/`). Full snapshots are named `crowdsec.backup.`, single-file saves `crowdsec.filebackup.`. Saved acquisition settings are backed up as `acquis.yaml.backup.` right beside the acquisition file instead. To recover by hand, stop CrowdSec, copy the backup you want back into place, and start it again. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 93dc6af7a..95f4cffe9 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,83 +1,64 @@ -# QA and Security Report: feat/crowdsec-hub-items-restore +# QA and Security Report: fix/crowdsec-followups-batch4 Date: 2026-10-11 -Scope: full diff `origin/development...HEAD` (HEAD 96260b17, 7 commits, 47 files) against `SECURITY.md`, `CLAUDE.md` (Definition of Done) and the approved batch-3 plan (with the Supervisor amendments). The branch adds a hub item list to the CrowdSec export, an import-time check of notification references, a new admin endpoint `POST /api/v1/admin/crowdsec/hub-items/restore` that installs missing hub items, the matching frontend panel, translated export/import messages, E2E specs and docs. +Scope: full diff `origin/development...HEAD` (11 branch commits plus the 1 QA commit listed below, 44 files) against `SECURITY.md`, `CLAUDE.md` (Definition of Done) and the approved batch-4 spec. The branch refuses a CrowdSec import whose configuration is not in the folder CrowdSec reads, keeps the current companion files an archive does not contain (and reports their names), makes the data lock abandonable when a request ends, classifies a missing hub item precisely, matches protected paths regardless of letter case, translates the remaining configuration page messages, and fixes two test-environment issues. ## Verdict: PASS (no blocking findings) -No critical, high or medium findings. Four low or informational observations (F1-F4) are listed; none blocks merge. No model, migration or GORM query file is in the diff, and no security-sensitive finding was found. One E2E test failed once on the first gate run because the local E2E container's 256 MB data tmpfs filled up (environment, see F1); the same test passes on a clean tmpfs. +No critical, high or medium findings. One test defect was found and fixed in the branch (F1); two informational observations (F2, F3) are listed. No model, migration or GORM query file is in the diff, and no security-sensitive defect was found in the import path. ## Definition of Done evidence | Check | Command | Result | |---|---|---| -| Build and vet | `cd backend && go build ./... && go vet ./...` | pass, no output | -| golangci-lint, backend | `golangci-lint run --config .golangci.yml ./...` (v2.14.0) | 0 issues | -| golangci-lint, agent | `cd agent && golangci-lint run --config ../backend/.golangci.yml ./...` | 0 issues | -| Frontend | `cd frontend && npm run type-check && npm run build` | pass | -| i18n keys | `vitest run src/pages/__tests__/CrowdSecConfig.i18nKeys.test.ts` | 6 passed; the `crowdsecConfig` key set (185 keys, including the new `hubItems` group) is identical in en, de, es, fr and zh | -| Lefthook | `lefthook run pre-commit --all-files` | all 17 hooks pass (795 s), including actionlint, shellcheck, semgrep (367 rules, 1946 files, 0 findings), frontend type-check and lint; the working tree stayed clean (only the three untracked plan files) | -| govulncheck | skill `security-scan-go-vuln` | 0 affected; 1 module-level advisory in a required module that Charon code does not call | -| Trivy | `trivy fs --scanners vuln,misconfig --severity HIGH,CRITICAL` on go.mod/go.sum (backend, agent) and the frontend package files, copied under `$HOME` (the snap trivy cannot read `/projects`) and removed afterwards | no HIGH/CRITICAL | -| CodeQL | `lefthook run codeql` (CLI 2.27.2, current) | Go: 4 results, all 4 suppressed pre-existing entries in `auth_handler.go`, `remote_server_handler.go`, `uptime_service.go` (none in a file of this diff); JS: 0 results; "0 blocking"; "All CodeQL checks passed"; parity check passed. None of the earlier log-injection findings in the restore code remain. | -| GORM scan | not run | no `backend/internal/models/**`, migration or GORM query file in the diff (0 matches) | -| Coverage (given, not rerun) | prior runs | backend 91.4% stmt / 90.4% line, frontend 91.52% lines, patch coverage 94.8% | -| Patch report artifacts | `test-results/local-patch-report.{md,json}` | produced by the earlier run; the directory is cleared by Playwright's output folder, so the files were gone after the E2E gate and were not regenerated (instruction: do not rerun coverage scripts) | +| Build | `cd backend && go build ./...` | pass | +| Backend tests and coverage | `scripts/go-test-coverage.sh` | first run: one failing test (F1); after the fix: all packages pass, statements 91.4%, lines 90.4% (gate 87%) | +| golangci-lint | `cd backend && golangci-lint run --config .golangci.yml ./...` (v2.14.0) | 0 issues | +| Frontend type-check and build | `cd frontend && npm run type-check && npm run build` | pass | +| Frontend coverage | `scripts/frontend-test-coverage.sh` | all tests pass; statements 90.38%, branches 83.92%, functions 88.34%, lines 91.53% (gate 87%) | +| Patch coverage | `bash scripts/local-patch-report.sh` (strict) | overall 91.6% (416/454), backend 92.0% (379/412), frontend 88.1% (37/42), agent n/a; all above the 87% gate; artifacts `test-results/local-patch-report.{md,json}` produced | +| CodeQL | `lefthook run codeql` (CLI 2.27.2) | Go: 4 results, all 4 pre-existing suppressed entries in files outside this diff; JS: 0 results; 0 blocking; parity check passed | +| Trivy | `trivy fs --scanners vuln,misconfig --severity HIGH,CRITICAL` on go.mod/go.sum and the frontend package files (copied under `$HOME` because the snap trivy cannot read `/projects`, removed afterwards) | no HIGH/CRITICAL | +| GORM scan | not run | no `backend/internal/models/**`, migration or GORM query file in the diff | +| E2E | rebuilt `charon-e2e` with `docker-rebuild-e2e`; `npx playwright test tests/security/crowdsec-import.spec.ts tests/security/crowdsec-diagnostics.spec.ts --project=security-tests` | 72 passed, 0 failed, 0 fixme or skipped (46.7 s) | + +Patch coverage detail (lines still uncovered in the patch): `companions.go` 89.0% (the remaining branches are write and close failures on a file just created, and the lost-race `O_EXCL` case, which need fault injection), `archive_inspect.go` 88.2% (defensive read errors), `crowdsec_handler.go` 92.1% (the 500 branch when keeping the current files fails, lines 824-828), `CrowdSecConfig.tsx` 86.5% (error toasts). -## Race and repeat runs - -| Run | Result | -|---|---| -| `go test -race -count=2 -run 'HubItems\|Restore\|Manifest\|Inspect\|Notification\|Export\|Import\|Keep\|Rollback' ./internal/crowdsec/... ./internal/api/handlers/...` | ok (crowdsec 5.7 s, handlers 64.6 s) | -| Full package, `go test -race -count=1 -timeout=40m ./internal/api/handlers/` | ok, 386.6 s, no failure. The earlier unrecorded one-off failure in this package was not reproduced; no test name is available to chase. | +## Security review -## E2E gate (single project, once, on a freshly rebuilt `charon-e2e`) +### 1. Import path: nothing destructive before validation -`npx playwright test tests/security/crowdsec-hub-items.spec.ts tests/security/crowdsec-import.spec.ts tests/security/crowdsec-config.spec.ts tests/security/crowdsec-diagnostics.spec.ts tests/security/crowdsec-file-editor.spec.ts tests/security-enforcement/zzz-security-ui/crowdsec-import.spec.ts --project=security-tests` +Order in `ImportConfig`: upload saved to a private temp folder, archive validator (size, ratio, required file, traversal), archive inspection and notification check, then the data lock, then the layout check, and only then the snapshot (a copy) and the clearing of the old tree. Every refusal before the snapshot changes nothing. After the snapshot, every failure calls `rollback()`: clearing failed, extraction failed, the flat-archive re-check after extraction, failure to keep the current files, and the kept-profile verification. The flat-archive refusal happens both before the snapshot (from the inspection) and after extraction (when the inspection was skipped), and returns a fixed message. -Result: 149 passed, 1 failed, no fixme remaining. The failure was `crowdsec-import.spec.ts:132 Rejected Archives > should reject an archive larger than 50 MB`, failing in its `beforeEach` baseline import with 500. Container log: `no space left on device` on the backup write; `/app/data` (256 MB tmpfs) was at 91%, holding about 155 MB of retained backups (each about 39 MB, because the data dir holds about 73 MB of CrowdSec data) and the data dir itself. After removing the backups and `crowdsec.backup.*` leftovers inside the throwaway container, the whole "Rejected Archives" group (5 tests, including that one) passed. `changelog.json` was restored with `git checkout`. See F1. +### 2. Archive entry names -## Security review +`ArchiveEntryRel` is the single normalisation shared by the validator (`requireArchiveFile`), the inspector and the extractor. Absolute names are re-rooted below the destination (as the extractor does), `..` sequences that would leave it are reported as invalid, `./` and doubled slashes collapse, and the required-file check only accepts the top level or `config/`, so a copy such as `parsers/config.yaml` does not count. Case variants are different names on the case-sensitive live filesystem and are never treated as the real configuration; the protected-path predicates (`IsHubPath`, `IsEngineOwnedPath`, `isLiveDBFile`) now lower-case their input, so a differently cased spelling of a protected path is skipped too (conservative direction). -### 1. Restore endpoint +### 3. Links and file writes -Verified live against the rebuilt `charon-e2e` container (validation paths only; no install was run, the hub was unreachable there): +- Presence of a companion in the live tree is decided with `Lstat`, so a link counts as present and is never replaced or written through. Snapshot entries that are links or have the wrong type are skipped and logged with a fixed message. +- Notification files are restored with `O_WRONLY|O_CREATE|O_EXCL` and mode 0600, after an `Lstat` of the destination; an existing path of any kind (regular file, link, dangling link) is skipped, never written through. The notifications folder is only created when absent, and refused when it is a link or a file. The new unit tests pin each of these (regular, linked and dangling destinations, linked folder, file in the way, read-only folder). +- Only direct children of `notifications/` with a lowercase `.yaml` or `.yml` name are considered, and only files that define a name the kept profile needs are copied back; a restore that would leave a name defined twice is refused (422) and rolled back. -| Probe | Result | -|---|---| -| No credentials | 401 | -| Authenticated user with role `user` (created for the probe) | 403 `Forbidden` | -| Admin, each invalid item: unknown type, `a/b..c`, leading `-` on owner and on name, NUL, trailing newline, embedded newline, Cyrillic look-alike character, `=`, space, two slashes, 129-character name, empty list, malformed JSON | 400 with the fixed messages `invalid hub item`, `no hub items given`, `invalid request` | -| 1,001 items | 400 `invalid hub item` | -| Body over 1 MiB | 413 `request too large` | -| Unknown JSON field alongside a valid item | ignored (valid item proceeds) | +### 4. Secrets and responses -- Route sits in `managementAdmin` (`RequireRole(admin)`), the same group as the sibling CrowdSec routes; a route test pins the 401. -- Argv is built as the literal `cscli`, then ``, `install`, ``, through the command executor (no shell). Name pattern `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` plus a `..` ban and a five-value type allowlist; Go RE2 `$` matches only at the end of text, so a trailing newline cannot slip through (probe above confirms). No validated string starts with `-` or contains `=`, whitespace or a second slash, so cobra cannot read it as a flag or extra argument; names beginning with a digit or containing dots are plain positional arguments. cscli v1.8.1 in the image was confirmed to be the version in use. -- Response bodies carry only fixed reason codes (`not_found`, `install_failed`, `verify_failed`, `not_attempted`) and fixed messages; cscli output and paths go to the log only. All log fields in the new code (`type`, `name`, `error`, `output`) pass through `hubLogDetail` (`sanitizeForLog` plus a 300-byte cap); the remaining log lines use fixed strings or filesystem errors that contain no request data. CodeQL confirms no log-injection result. -- Concurrency: a compare-and-swap single-flight flag (never waited on) returns 409 to a second call; each item runs under `dataMu` for one cscli section with a context of min(remaining overall time, 120 s); the overall budget is 5 minutes; the hub update has 60 s; the reload uses a detached 10 s context so an install that succeeded just before a disconnect is still reloaded. -- Lock order `restore.running -> dataMu -> HubService.mu`: all seven `dataMu` users (import, export, file write, preset apply, and the three restore sections) take it non-nested and never call another `dataMu` taker while holding it; `installedHubSet` is the only restore helper that locks and is called without the lock held; the reload runs after every lock is released. No re-entrancy or inversion was found, and the full race run passed. -- Resource use: an admin can start at most one restore at a time (409 otherwise), at most 1,000 items per call, at most 5 minutes per call, and other `dataMu` users wait for at most one 120 s section. The 1 MiB body cap is applied before the flag is taken; slow body reads are a general server property (see F4). +`kept_files` carries file names only (fixed companion names plus `notifications/`), never contents. Error messages are fixed strings or sanitised, length-bounded name lists; I/O errors are wrapped through `withoutPath` so paths are not returned or logged; log fields pass through `SanitizeForLog`. No token or key is logged in the new code. -### 2. Manifest path +### 5. Locking (`ctxMutex`) -- `ArchiveEntryRel` is now the single normalization used by both the extractor and `InspectArchive`, so `./charon-hub-items.json`, `a/../charon-hub-items.json` and `charon-hub-items.json` are the same entry for both; a nested `x/charon-hub-items.json` is an ordinary file. The extractor skips the manifest path regardless of the entry type, so it is never written to disk (E2E "never writes the manifest to disk" and the `./` spelling spec pass). -- Bounds: manifest at most 1 MiB, a duplicate entry ignores the whole manifest, malformed JSON, unknown version and more than 1,000 items ignore it, an individually invalid item is dropped and counted (`skipped_invalid`, names not echoed). `InspectArchive` keeps at most 1 MiB per entry, 8 MiB in total and 256 files, and sets `Incomplete` instead of failing. -- Export side: the manifest is derived from link targets only; the target is cleaned and parsed as `hub//[stage/]/.yaml`, and the name is re-validated with the same function the restore uses. The test table covers relative links, links elsewhere, wrong type, missing stage, too deep, odd extension, invalid name, overlong owner and a dangling target. A file with the reserved name inside the data dir is skipped on export. +`LockContext` returns an error without taking the lock when the context ends first (an already ended context never takes a free lock); `Unlock` panics when not held. All call sites register `defer Unlock()` only after a successful lock (`lockData` returns false with the lock not held and the callers return before the defer; `writeExportLocked`, `hubUpdate`, `installedHubSet` and `installHubItem` return before the defer on error). No double unlock and no leaked lock was found. The restore single-flight flag is a never-waited compare-and-swap taken before any `dataMu` section and the import never takes it, so the lock order `restore flag -> dataMu` cannot invert. A cancelled waiter releasing nothing is covered by the new cancel tests, and the earlier observation about a non-context-aware lock no longer applies. -### 3. Notification check +### 6. Hub item classification -- The check runs after the archive validator and before the lock, so a refusal changes nothing (E2E and handler tests assert the live files are untouched). It skips instead of failing when the inspection is incomplete, there is no profiles file, the config redirects the paths, a profile or notification file does not parse, or a notifications value is not a list of names. -- The 422 message lists at most 10 names, each sanitized and cut at 64 characters, plus "and N more". -- YAML alias abuse: a temporary probe fed alias-expansion documents of 5 to 40 nested levels (up to 2.1 KB) through `CheckProfileNotifications` in both profiles.yaml and a notification file. Each finished in 1-2 ms with under 400 KB allocated in total (the parser rejects the excessive aliasing before expansion, and the check then skips). The probe file was removed; the working tree is clean. +`hubItemNotFound` now matches only cscli's sentence for the requested type and name; any other wording is `install_failed` (safe direction). A failed second read of the installed list now returns 500 with what already happened instead of claiming items were not attempted. -### 4. Frontend panel +### 7. Frontend -- No `dangerouslySetInnerHTML` in the panel or the page; item type and name are rendered as React text; error messages are fixed i18n strings chosen from the HTTP status (502, 409, other); the result area is a `role="status"` `aria-live="polite"` region and the section carries `aria-busy`; the 330 s timeout applies only to the restore call; the panel is passive (no banner, no toast; only the existing import success toast). The import failure toast shows the server message (including the 422 notification text) through the existing response interceptor. +Archive-derived names are rendered as React text (no `dangerouslySetInnerHTML` or `innerHTML` in the page or the helper); `sanitizeKeptFileName` strips control, format (including direction override) and line/paragraph separator characters and caps the length at 80. The translation key sets for the page are identical in en, de, es, fr and zh (checked by the i18n key test). -### 5. Commits and docs +### 8. Commits and docs -- Subjects: `docs: describe the CrowdSec hub item list and the import check`, `feat: offer to install missing hub items after a CrowdSec import`, `feat: add an action to install missing CrowdSec hub items`, `feat: record the installed hub items in a CrowdSec export`, `fix: validate notification references in a CrowdSec import`, `fix: translate the CrowdSec export and import messages`, `test(e2e): add fixme specs for the CrowdSec import checks and hub item restore`. All generic; none uses the `(security)` scope; no session ID or link in any message. The docs and `ARCHITECTURE.md` additions describe behavior only, with no file names, process IDs or secret details. +Subjects are generic; none uses the `(security)` scope; no session id or link in any message. Docs additions describe behaviour only. ## Findings @@ -85,18 +66,18 @@ No critical, high or medium findings. ### Low -- **F1: low (test environment, not product).** `tests/security/crowdsec-import.spec.ts:132` (and any import spec late in a full gate run): the local E2E container's `/app/data` is a 256 MB tmpfs, and each import and manual backup writes a roughly 39 MB zip (the data dir holds about 73 MB of CrowdSec data). The gate filled the tmpfs once during the CrowdSec config/import specs and one `beforeEach` import returned 500 (`no space left on device`). Evidence: container log lines `checksum writer: write /app/data/backups/...: no space left on device` and `df` showing 91% used with 4 retained backups. Suggested approach: raise the tmpfs size in the E2E compose file, or have the import/config specs delete the backups they create. Already known behavior ("fills up if the specs run twice"), but it now happens on a single run, so CI on a fresh container should be watched (CI uses its own container; a failure there would look the same). +- **F1: low (test defect, fixed in this review).** `backend/internal/api/handlers/crowdsec_export_import_test.go` `TestEditorHidesPidBouncersAndHub` still asserted that `config/Hub/x.yaml` is writable, which contradicts the case-insensitive hub matching added by the protected-path commit. It failed deterministically in the handlers package (`go test` exit 1, although the coverage gate message said "met"). The assertion now expects the path to be protected. Evidence: the first `scripts/go-test-coverage.sh` run reported this single failure; after the fix the whole run passes. ### Informational -- **F2: info.** `backend/internal/api/handlers/crowdsec_hub_items.go` `hubItemNotFound`: classifies `not_found` by matching "can't find" or "not found" (case-insensitive) in cscli output. It is best effort by design; a different cscli wording only turns `not_found` into `install_failed`, with the same safe response. Nothing to change unless cscli is upgraded; the wording was verified against the real hub in the manual run. -- **F3: info.** `backend/internal/api/handlers/crowdsec_hub_items.go` `installHubItem` / `hubUpdate`: `dataMu.Lock()` is not context-aware, so a restore whose client has left still holds the single-flight flag until it gets the lock and notices the cancelled context (at most one other `dataMu` section, itself bounded). No action needed; mention only for operators who see a 409 right after a cancelled restore. -- **F4: info.** The restore body read (1 MiB cap) has no per-request read deadline of its own; slow-client behavior is whatever the HTTP server configuration provides, as for every other admin endpoint. Admin-only, so not a new exposure. +- **F2: info.** `InspectTree` reads the live tree and the pre-import snapshot following links (as CrowdSec does) to parse configuration and notification names. The snapshot keeps links as links, so a linked notifications folder in the live tree is read through, but the contents are only parsed for names and a file is copied back only when it defines a needed name and the file itself is a regular file. This needs an administrator-created link on the server; no action needed. +- **F3: info.** The 500 branch "failed to keep the current CrowdSec files" in `ImportConfig` (rollback after a companion restore error) has no handler-level test; the underlying function's error paths are covered in the package tests. Reaching it from the handler needs a failure injected into a copy, so it was not added. ## Known limitations (not re-opened) -Flat archives (#1559), the image ships no notification plugins (#1551 comment), profiles file hazard (#1554), restore edge cases (#1556). +Redacting sensitive values in notification files on export is tracked separately (#1551); the image ships no notification plugins. -## Manual evidence (given, on throwaway containers at HEAD bcda295d, not repeated here) +## Commits added by this review -Real-hub restore of 2 real items and 1 bogus (200, bogus reported as `not_found`); export manifest with 61 items; cross-host import reporting the missing items; restore on the second host, then a second restore reporting everything as already installed; a parallel restore refused with 409; import and export waiting during a 34 s ten-collection restore (export about 10.9 s, import about 6.5 s); a real 422 on a missing notification and 200 once defined; an offline restart that is non-fatal. +- `test: cover the defensive branches of the CrowdSec notification file restore and keep hub paths protected in the editor test` (new tests for `prepareNotificationsDir` 77.8% -> 100% and `writeNotificationFile` 62.5% -> 75%, plus the F1 assertion fix). +- `docs: update QA report for the CrowdSec follow-up batch` (this report). diff --git a/docs/troubleshooting/crowdsec.md b/docs/troubleshooting/crowdsec.md index 6b77ca9c0..d213d9cb8 100644 --- a/docs/troubleshooting/crowdsec.md +++ b/docs/troubleshooting/crowdsec.md @@ -194,6 +194,8 @@ charon-local-machine 127.0.0.1 password v1.x.x - Hub returns HTML/redirect: set HUB_BASE_URL to the JSON endpoint above or install cscli so the index is fetched locally. - Bad preset slug (400): the slug must match Hub naming; correct the slug before retrying. - **"Install missing hub items" says the hub could not be reached**: nothing was installed and nothing was changed. Check that the container has internet access and can resolve names (DNS), then click the button again. Items that were not reached in time can be retried with **Install remaining**. +- **Import refused because the archive keeps its configuration at the top level**: this CrowdSec reads its settings from the `config/` folder. Export a configuration from the CrowdSec page, or put your files in a `config/` folder inside the archive. Nothing was changed. See the [CrowdSec feature guide](../features/crowdsec.md). +- **Import refused after saying the current profile was kept**: your archive has no `profiles.yaml`, so Charon kept the current one, but it names notifications your archive has no files for. Add those notification files to the archive, or include your own `profiles.yaml`. Nothing was changed. - **Import refused because a profile names notifications that have no matching file**: your imported alert profiles refer to notification names that are not in the archive. Add the missing notification files to the configuration, or remove those names from the profile, then import again. Nothing was changed by the refused import. - Apply failed: review the apply response and restore from the backup that was taken automatically, then retry after fixing the underlying issue. - Apply not supported (501): use curated/offline presets; Hub apply will be re-enabled when supported in your environment. diff --git a/frontend/src/api/crowdsec.ts b/frontend/src/api/crowdsec.ts index f8b47ef71..86373d1f7 100644 --- a/frontend/src/api/crowdsec.ts +++ b/frontend/src/api/crowdsec.ts @@ -73,6 +73,8 @@ export interface ImportCrowdsecResult { status?: string backup?: string hub_items?: ImportHubItems + /** Names of the current files kept because the archive did not contain them. */ + kept_files?: string[] } /** Result of a hub item restore. */ diff --git a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx index 1b19c19ca..104486d5f 100644 --- a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx @@ -206,7 +206,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('allows changing ACL selection after initial selection', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() render( @@ -252,7 +252,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('allows removing ACL selection', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() render( @@ -295,7 +295,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('allows changing Security Headers selection after initial selection', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() render( @@ -333,7 +333,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('allows removing Security Headers selection', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() render( @@ -371,7 +371,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('allows editing existing host with ACL and changing it', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const existingHost: ProxyHost = { @@ -436,7 +436,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('persists null to value transitions for ACL and security headers in edit flow', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const existingHostWithNulls: ProxyHost = { @@ -492,7 +492,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('resets ACL/security header form state when editing target host changes', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const firstHost: ProxyHost = { @@ -562,7 +562,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('persists ACL and security header selections with UUID-only option payloads', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const uuidOnlyAccessLists = [ @@ -626,7 +626,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('submits numeric ACL value when ACL option id is a numeric string', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const stringIdAccessLists = [ @@ -675,7 +675,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('initializes edit mode from nested ACL and security header UUID references', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const existingHost = { @@ -727,7 +727,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('normalizes empty and numeric-string ACL/security references on submit', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() const hostWithStringReferences = { @@ -777,7 +777,7 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { }) it('filters out security profiles missing both id and uuid', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const Wrapper = createWrapper() vi.mocked(useSecurityHeaderProfiles).mockReturnValue({ diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 19efa84f6..f3582f8c1 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -1185,6 +1185,7 @@ }, "importSuccess": "CrowdSec-Konfiguration importiert (Sicherung erstellt)", "importFailed": "Import fehlgeschlagen", + "importKeptFiles": "Diese Dateien waren nicht im Archiv, daher wurden die aktuellen beibehalten: {{files}}", "exportPrompt": "Name für das CrowdSec-Exportarchiv", "title": "CrowdSec-Konfiguration", "checkNow": "Jetzt prüfen", @@ -1234,7 +1235,16 @@ "lastHeartbeat": "Letzter Heartbeat", "privacyNote": "Hinweis: Die Registrierung sendet Heartbeat-Daten und Metriken an CrowdSec. Details finden Sie in der Datenschutzrichtlinie von CrowdSec.", "tenantHelper": "Optional: Mandanten-/Organisations-ID angeben, falls Sie ein Multi-Tenant-Setup verwenden", - "tokenHelper": "Holen Sie sich Ihr Registrierungstoken aus der CrowdSec Console" + "tokenHelper": "Holen Sie sich Ihr Registrierungstoken aus der CrowdSec Console", + "tokenStored": "Gespeichert (maskiert)", + "tokenNotStored": "Nicht gespeichert", + "enrollSubmitted": "Registrierung übermittelt! Bestätigen Sie die Anfrage auf app.crowdsec.net, um sie abzuschließen.", + "enrollSent": "Registrierungsanfrage gesendet! Bestätigen Sie die Registrierung auf app.crowdsec.net, um sie abzuschließen.", + "errors": { + "agentRequired": "Agentenname ist erforderlich", + "ackRequired": "Sie müssen den Hinweis zur Datenweitergabe an die Konsole bestätigen", + "enrollFailed": "Registrierung bei der Konsole fehlgeschlagen" + } }, "reenroll": { "title": "Neu-Registrierungsoptionen", @@ -1294,7 +1304,15 @@ "loadCached": "Zwischengespeicherte Vorschau laden", "applyDisabled": "Anwenden deaktiviert, solange Hub offline ist.", "noPresets": "Keine Vorlagen verfügbar. Stellen Sie sicher, dass Cerberus aktiviert ist.", - "sortBy": "Presets sortieren" + "sortBy": "Presets sortieren", + "status": { + "previewFetched": "Vorschau vom Hub abgerufen", + "hubUnavailable": "CrowdSec-Hub nicht verfügbar. Erneut versuchen oder die zwischengespeicherte Kopie laden.", + "pullFailed": "Vorschau der Vorlage konnte nicht abgerufen werden", + "cachedLoaded": "Zwischengespeicherte Vorschau geladen", + "cacheLoadFailed": "Zwischengespeicherte Vorschau konnte nicht geladen werden", + "slugInvalid": "Der Vorlagen-Slug ist ungültig" + } }, "files": { "title": "Konfigurationsdateien bearbeiten", @@ -1363,10 +1381,47 @@ "title": "Whitelist-Eintrag entfernen", "body": "{{ip}} von der Whitelist entfernen? CrowdSec kann diese IP dann blockieren, wenn sie Warnungen auslöst.", "submit": "Entfernen" - } + }, + "addFailed": "Eintrag konnte nicht hinzugefügt werden" }, "noConfig": "CrowdSec-Konfiguration im Sicherheitsstatus nicht gefunden", - "noteText": "CrowdSec wird über den Schalter auf der Seite gesteuert:" + "noteText": "CrowdSec wird über den Schalter auf der Seite gesteuert:", + "toasts": { + "fileSaved": "Datei gespeichert", + "saveFailed": "Datei konnte nicht gespeichert werden", + "ipBanned": "IP {{ip}} wurde gesperrt", + "banFailed": "IP konnte nicht gesperrt werden", + "ipUnbanned": "Sperre für IP {{ip}} wurde aufgehoben", + "unbanFailed": "Sperre der IP konnte nicht aufgehoben werden", + "ipDetectFailed": "Ihre IP-Adresse konnte nicht ermittelt werden", + "selectPreset": "Wählen Sie eine Vorlage zum Anwenden aus", + "presetNotCompleted": "Das Anwenden der Vorlage wurde nicht abgeschlossen (Status: {{status}})", + "statusUnknown": "unbekannt", + "presetApplied": "Vorlage über das Backend angewendet", + "presetAppliedReload": "Vorlage über das Backend angewendet (Neuladen erforderlich)", + "presetApplyFailed": "Vorlage konnte nicht angewendet werden", + "applyFailed": "Anwenden fehlgeschlagen: {{message}}", + "applyFailedWithBackup": "Anwenden fehlgeschlagen: {{message}}. {{backup}}" + }, + "overlay": { + "fetchingPreset": "Vorlage wird abgerufen...", + "fetchingPresetDetail": "Vorschau wird vom CrowdSec-Hub geladen", + "loadingPreset": "Vorlage wird geladen...", + "loadingPresetDetail": "Kuratierte Vorlage wird mit Backup angewendet", + "importing": "Der Wächter wird gerufen...", + "importingDetail": "CrowdSec-Konfiguration wird importiert", + "saving": "Der Wächter schreibt...", + "savingDetail": "Konfigurationsdatei wird gespeichert", + "banning": "Der Wächter hebt den Schild...", + "banningDetail": "IP-Adresse wird gesperrt", + "unbanning": "Der Wächter senkt den Schild...", + "unbanningDetail": "Sperre der IP-Adresse wird aufgehoben", + "updatingList": "Der Wächter aktualisiert die Liste...", + "whitelistAddingDetail": "IP wird zur Whitelist hinzugefügt", + "whitelistRemovingDetail": "Wird von der Whitelist entfernt", + "default": "Die Wache wird verstärkt...", + "defaultDetail": "Konfiguration läuft" + } }, "smtp": { "title": "E-Mail (SMTP) Einstellungen", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index ca4cabb00..f1864d444 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -1506,6 +1506,7 @@ }, "importSuccess": "CrowdSec config imported (backup created)", "importFailed": "Failed to import", + "importKeptFiles": "These files were not in the archive, so the current ones were kept: {{files}}", "exportPrompt": "Name your CrowdSec export archive", "title": "CrowdSec Configuration", "checkNow": "Check Now", @@ -1557,7 +1558,16 @@ "lastAttempt": "Last attempt", "enrolledAt": "Enrolled", "correlationId": "Correlation ID", - "lastError": "Last error" + "lastError": "Last error", + "tokenStored": "Stored (masked)", + "tokenNotStored": "Not stored", + "enrollSubmitted": "Enrollment submitted! Accept the request on app.crowdsec.net to complete.", + "enrollSent": "Enrollment request sent! Accept the enrollment on app.crowdsec.net to complete registration.", + "errors": { + "agentRequired": "Agent name is required", + "ackRequired": "You must acknowledge the console data-sharing notice", + "enrollFailed": "Console enrollment failed" + } }, "reenroll": { "title": "Re-enrollment Options", @@ -1617,7 +1627,15 @@ "loadCached": "Load cached preview", "applyDisabled": "Apply disabled while hub is offline.", "noPresets": "No presets available. Ensure Cerberus is enabled.", - "sortBy": "Sort presets" + "sortBy": "Sort presets", + "status": { + "previewFetched": "Preview fetched from hub", + "hubUnavailable": "CrowdSec hub unavailable. Retry or load cached copy.", + "pullFailed": "Failed to pull preset preview", + "cachedLoaded": "Loaded cached preview", + "cacheLoadFailed": "Failed to load cached preview", + "slugInvalid": "Preset slug is invalid" + } }, "files": { "title": "Edit Configuration Files", @@ -1686,7 +1704,44 @@ "title": "Remove Whitelist Entry", "body": "Remove {{ip}} from the whitelist? CrowdSec may then block this IP if it triggers alerts.", "submit": "Remove" - } + }, + "addFailed": "Failed to add entry" + }, + "toasts": { + "fileSaved": "File saved", + "saveFailed": "Failed to save file", + "ipBanned": "IP {{ip}} has been banned", + "banFailed": "Failed to ban IP", + "ipUnbanned": "IP {{ip}} has been unbanned", + "unbanFailed": "Failed to unban IP", + "ipDetectFailed": "Failed to detect your IP address", + "selectPreset": "Select a preset to apply", + "presetNotCompleted": "Preset apply did not complete (status: {{status}})", + "statusUnknown": "unknown", + "presetApplied": "Preset applied via backend", + "presetAppliedReload": "Preset applied via backend (reload required)", + "presetApplyFailed": "Failed to apply preset", + "applyFailed": "Apply failed: {{message}}", + "applyFailedWithBackup": "Apply failed: {{message}}. {{backup}}" + }, + "overlay": { + "fetchingPreset": "Fetching preset...", + "fetchingPresetDetail": "Pulling preview from CrowdSec Hub", + "loadingPreset": "Loading preset...", + "loadingPresetDetail": "Applying curated preset with backup", + "importing": "Summoning the guardian...", + "importingDetail": "Importing CrowdSec configuration", + "saving": "Guardian inscribes...", + "savingDetail": "Saving configuration file", + "banning": "Guardian raises shield...", + "banningDetail": "Banning IP address", + "unbanning": "Guardian lowers shield...", + "unbanningDetail": "Unbanning IP address", + "updatingList": "Guardian updates list...", + "whitelistAddingDetail": "Adding IP to whitelist", + "whitelistRemovingDetail": "Removing from whitelist", + "default": "Strengthening the guard...", + "defaultDetail": "Configuration in progress" } }, "smtp": { diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index cf73d3d45..a3d9d8fff 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -1187,6 +1187,7 @@ }, "importSuccess": "Configuración de CrowdSec importada (copia de seguridad creada)", "importFailed": "Error al importar", + "importKeptFiles": "Estos archivos no estaban en el archivo comprimido, por lo que se conservaron los actuales: {{files}}", "exportPrompt": "Nombre del archivo de exportación de CrowdSec", "title": "Configuración de CrowdSec", "checkNow": "Comprobar ahora", @@ -1236,7 +1237,16 @@ "lastHeartbeat": "Último latido", "privacyNote": "Nota: El registro envía datos de latido y métricas a CrowdSec. Consulte la política de privacidad de CrowdSec para más detalles.", "tenantHelper": "Opcional: indique el ID de inquilino/organización si usa una configuración multiinquilino", - "tokenHelper": "Obtenga su token de registro en la consola de CrowdSec" + "tokenHelper": "Obtenga su token de registro en la consola de CrowdSec", + "tokenStored": "Almacenado (enmascarado)", + "tokenNotStored": "No almacenado", + "enrollSubmitted": "¡Inscripción enviada! Acepte la solicitud en app.crowdsec.net para completarla.", + "enrollSent": "¡Solicitud de inscripción enviada! Acepte la inscripción en app.crowdsec.net para completar el registro.", + "errors": { + "agentRequired": "El nombre del agente es obligatorio", + "ackRequired": "Debe confirmar el aviso de uso compartido de datos de la consola", + "enrollFailed": "Error en la inscripción en la consola" + } }, "reenroll": { "title": "Opciones de reinscripción", @@ -1296,7 +1306,15 @@ "loadCached": "Cargar vista previa en caché", "applyDisabled": "Aplicar deshabilitado mientras el hub está fuera de línea.", "noPresets": "No hay plantillas disponibles. Asegúrese de que Cerberus esté habilitado.", - "sortBy": "Ordenar presets" + "sortBy": "Ordenar presets", + "status": { + "previewFetched": "Vista previa obtenida del hub", + "hubUnavailable": "El hub de CrowdSec no está disponible. Reintente o cargue la copia en caché.", + "pullFailed": "No se pudo obtener la vista previa del preajuste", + "cachedLoaded": "Vista previa en caché cargada", + "cacheLoadFailed": "No se pudo cargar la vista previa en caché", + "slugInvalid": "El identificador del preajuste no es válido" + } }, "files": { "title": "Editar archivos de configuración", @@ -1365,10 +1383,47 @@ "title": "Eliminar entrada de la lista blanca", "body": "¿Eliminar {{ip}} de la lista blanca? CrowdSec podrá bloquear esta IP si genera alertas.", "submit": "Eliminar" - } + }, + "addFailed": "No se pudo añadir la entrada" }, "noConfig": "No se encontró la configuración de CrowdSec en el estado de seguridad", - "noteText": "CrowdSec se controla mediante el interruptor de la página" + "noteText": "CrowdSec se controla mediante el interruptor de la página", + "toasts": { + "fileSaved": "Archivo guardado", + "saveFailed": "No se pudo guardar el archivo", + "ipBanned": "La IP {{ip}} ha sido bloqueada", + "banFailed": "No se pudo bloquear la IP", + "ipUnbanned": "La IP {{ip}} ha sido desbloqueada", + "unbanFailed": "No se pudo desbloquear la IP", + "ipDetectFailed": "No se pudo detectar su dirección IP", + "selectPreset": "Seleccione un preajuste para aplicar", + "presetNotCompleted": "La aplicación del preajuste no se completó (estado: {{status}})", + "statusUnknown": "desconocido", + "presetApplied": "Preajuste aplicado mediante el backend", + "presetAppliedReload": "Preajuste aplicado mediante el backend (se requiere recargar)", + "presetApplyFailed": "No se pudo aplicar el preajuste", + "applyFailed": "Error al aplicar: {{message}}", + "applyFailedWithBackup": "Error al aplicar: {{message}}. {{backup}}" + }, + "overlay": { + "fetchingPreset": "Obteniendo preajuste...", + "fetchingPresetDetail": "Descargando la vista previa del hub de CrowdSec", + "loadingPreset": "Cargando preajuste...", + "loadingPresetDetail": "Aplicando el preajuste curado con copia de seguridad", + "importing": "Invocando al guardián...", + "importingDetail": "Importando la configuración de CrowdSec", + "saving": "El guardián inscribe...", + "savingDetail": "Guardando el archivo de configuración", + "banning": "El guardián alza el escudo...", + "banningDetail": "Bloqueando la dirección IP", + "unbanning": "El guardián baja el escudo...", + "unbanningDetail": "Desbloqueando la dirección IP", + "updatingList": "El guardián actualiza la lista...", + "whitelistAddingDetail": "Añadiendo la IP a la lista blanca", + "whitelistRemovingDetail": "Quitando de la lista blanca", + "default": "Reforzando la guardia...", + "defaultDetail": "Configuración en curso" + } }, "smtp": { "title": "Configuración de Correo (SMTP)", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 558592087..2dfe5c15a 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -1187,6 +1187,7 @@ }, "importSuccess": "Configuration CrowdSec importée (sauvegarde créée)", "importFailed": "Échec de l'importation", + "importKeptFiles": "Ces fichiers n'étaient pas dans l'archive, les fichiers actuels ont donc été conservés : {{files}}", "exportPrompt": "Nom de l'archive d'exportation CrowdSec", "title": "Configuration CrowdSec", "checkNow": "Vérifier maintenant", @@ -1236,7 +1237,16 @@ "lastHeartbeat": "Dernier signal de vie", "privacyNote": "Remarque : l'inscription envoie des signaux de vie et des métriques à CrowdSec. Consultez la politique de confidentialité de CrowdSec pour plus de détails.", "tenantHelper": "Facultatif : indiquez l'ID du locataire/de l'organisation si vous utilisez une configuration multi-locataire", - "tokenHelper": "Obtenez votre jeton d'inscription depuis la console CrowdSec" + "tokenHelper": "Obtenez votre jeton d'inscription depuis la console CrowdSec", + "tokenStored": "Enregistré (masqué)", + "tokenNotStored": "Non enregistré", + "enrollSubmitted": "Inscription envoyée ! Acceptez la demande sur app.crowdsec.net pour la finaliser.", + "enrollSent": "Demande d'inscription envoyée ! Acceptez l'inscription sur app.crowdsec.net pour finaliser l'enregistrement.", + "errors": { + "agentRequired": "Le nom de l'agent est obligatoire", + "ackRequired": "Vous devez accepter l'avis de partage de données avec la console", + "enrollFailed": "Échec de l'inscription à la console" + } }, "reenroll": { "title": "Options de réinscription", @@ -1296,7 +1306,15 @@ "loadCached": "Charger l'aperçu en cache", "applyDisabled": "Application désactivée tant que le hub est hors ligne.", "noPresets": "Aucun préréglage disponible. Assurez-vous que Cerberus est activé.", - "sortBy": "Trier les préréglages" + "sortBy": "Trier les préréglages", + "status": { + "previewFetched": "Aperçu récupéré depuis le hub", + "hubUnavailable": "Hub CrowdSec indisponible. Réessayez ou chargez la copie en cache.", + "pullFailed": "Échec de la récupération de l'aperçu du préréglage", + "cachedLoaded": "Aperçu en cache chargé", + "cacheLoadFailed": "Échec du chargement de l'aperçu en cache", + "slugInvalid": "L'identifiant du préréglage n'est pas valide" + } }, "files": { "title": "Modifier les fichiers de configuration", @@ -1365,10 +1383,47 @@ "title": "Supprimer l'entrée de la liste blanche", "body": "Supprimer {{ip}} de la liste blanche ? CrowdSec pourra alors bloquer cette IP si elle déclenche des alertes.", "submit": "Supprimer" - } + }, + "addFailed": "Échec de l'ajout de l'entrée" }, "noConfig": "Configuration de CrowdSec introuvable dans l'état de sécurité", - "noteText": "CrowdSec est contrôlé via l'interrupteur de la page" + "noteText": "CrowdSec est contrôlé via l'interrupteur de la page", + "toasts": { + "fileSaved": "Fichier enregistré", + "saveFailed": "Échec de l'enregistrement du fichier", + "ipBanned": "L'IP {{ip}} a été bannie", + "banFailed": "Échec du bannissement de l'IP", + "ipUnbanned": "L'IP {{ip}} a été débannie", + "unbanFailed": "Échec du débannissement de l'IP", + "ipDetectFailed": "Échec de la détection de votre adresse IP", + "selectPreset": "Sélectionnez un préréglage à appliquer", + "presetNotCompleted": "L'application du préréglage n'a pas abouti (statut : {{status}})", + "statusUnknown": "inconnu", + "presetApplied": "Préréglage appliqué via le backend", + "presetAppliedReload": "Préréglage appliqué via le backend (rechargement requis)", + "presetApplyFailed": "Échec de l'application du préréglage", + "applyFailed": "Échec de l'application : {{message}}", + "applyFailedWithBackup": "Échec de l'application : {{message}}. {{backup}}" + }, + "overlay": { + "fetchingPreset": "Récupération du préréglage...", + "fetchingPresetDetail": "Téléchargement de l'aperçu depuis le hub CrowdSec", + "loadingPreset": "Chargement du préréglage...", + "loadingPresetDetail": "Application du préréglage sélectionné avec sauvegarde", + "importing": "Invocation du gardien...", + "importingDetail": "Importation de la configuration CrowdSec", + "saving": "Le gardien inscrit...", + "savingDetail": "Enregistrement du fichier de configuration", + "banning": "Le gardien lève son bouclier...", + "banningDetail": "Bannissement de l'adresse IP", + "unbanning": "Le gardien baisse son bouclier...", + "unbanningDetail": "Débannissement de l'adresse IP", + "updatingList": "Le gardien met à jour la liste...", + "whitelistAddingDetail": "Ajout de l'IP à la liste blanche", + "whitelistRemovingDetail": "Retrait de la liste blanche", + "default": "Renforcement de la garde...", + "defaultDetail": "Configuration en cours" + } }, "smtp": { "title": "Paramètres Email (SMTP)", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 5b46c2bf9..7f3b96fdb 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -1186,6 +1186,7 @@ }, "importSuccess": "CrowdSec 配置已导入(已创建备份)", "importFailed": "导入失败", + "importKeptFiles": "归档中没有这些文件,因此保留了当前的文件:{{files}}", "exportPrompt": "为 CrowdSec 导出文件命名", "title": "CrowdSec 配置", "checkNow": "立即检查", @@ -1235,7 +1236,16 @@ "lastHeartbeat": "上次心跳", "privacyNote": "注意:注册会向 CrowdSec 发送心跳数据和指标。详情请参阅 CrowdSec 的隐私政策。", "tenantHelper": "可选:如果使用多租户设置,请指定租户/组织 ID", - "tokenHelper": "从 CrowdSec 控制台获取您的注册令牌" + "tokenHelper": "从 CrowdSec 控制台获取您的注册令牌", + "tokenStored": "已存储(已隐藏)", + "tokenNotStored": "未存储", + "enrollSubmitted": "注册已提交!请在 app.crowdsec.net 上接受请求以完成注册。", + "enrollSent": "注册请求已发送!请在 app.crowdsec.net 上接受注册以完成登记。", + "errors": { + "agentRequired": "必须填写代理名称", + "ackRequired": "您必须确认控制台数据共享须知", + "enrollFailed": "控制台注册失败" + } }, "reenroll": { "title": "重新注册选项", @@ -1295,7 +1305,15 @@ "loadCached": "加载缓存的预览", "applyDisabled": "Hub 离线时禁用应用。", "noPresets": "没有可用的预设。请确保 Cerberus 已启用。", - "sortBy": "排序预设" + "sortBy": "排序预设", + "status": { + "previewFetched": "已从 Hub 获取预览", + "hubUnavailable": "CrowdSec Hub 不可用。请重试或加载缓存副本。", + "pullFailed": "获取预设预览失败", + "cachedLoaded": "已加载缓存的预览", + "cacheLoadFailed": "加载缓存的预览失败", + "slugInvalid": "预设标识无效" + } }, "files": { "title": "编辑配置文件", @@ -1364,10 +1382,47 @@ "title": "移除白名单条目", "body": "要将 {{ip}} 从白名单中移除吗?如果该 IP 触发警报,CrowdSec 之后可能会拦截它。", "submit": "移除" - } + }, + "addFailed": "添加条目失败" }, "noConfig": "在安全状态中未找到 CrowdSec 配置", - "noteText": "CrowdSec 由以下页面上的开关控制:" + "noteText": "CrowdSec 由以下页面上的开关控制:", + "toasts": { + "fileSaved": "文件已保存", + "saveFailed": "保存文件失败", + "ipBanned": "IP {{ip}} 已被封禁", + "banFailed": "封禁 IP 失败", + "ipUnbanned": "IP {{ip}} 已解除封禁", + "unbanFailed": "解除 IP 封禁失败", + "ipDetectFailed": "无法检测您的 IP 地址", + "selectPreset": "请选择要应用的预设", + "presetNotCompleted": "预设应用未完成(状态:{{status}})", + "statusUnknown": "未知", + "presetApplied": "已通过后端应用预设", + "presetAppliedReload": "已通过后端应用预设(需要重新加载)", + "presetApplyFailed": "应用预设失败", + "applyFailed": "应用失败:{{message}}", + "applyFailedWithBackup": "应用失败:{{message}}。{{backup}}" + }, + "overlay": { + "fetchingPreset": "正在获取预设...", + "fetchingPresetDetail": "正在从 CrowdSec Hub 拉取预览", + "loadingPreset": "正在加载预设...", + "loadingPresetDetail": "正在应用精选预设并备份", + "importing": "正在召唤守护者...", + "importingDetail": "正在导入 CrowdSec 配置", + "saving": "守护者正在刻写...", + "savingDetail": "正在保存配置文件", + "banning": "守护者举起盾牌...", + "banningDetail": "正在封禁 IP 地址", + "unbanning": "守护者放下盾牌...", + "unbanningDetail": "正在解除 IP 地址封禁", + "updatingList": "守护者正在更新名单...", + "whitelistAddingDetail": "正在将 IP 添加到白名单", + "whitelistRemovingDetail": "正在从白名单中移除", + "default": "正在加强守卫...", + "defaultDetail": "配置进行中" + } }, "smtp": { "title": "电子邮件 (SMTP) 设置", diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index bbf04619d..c41a4220f 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -23,6 +23,7 @@ import { CROWDSEC_PRESETS, type CrowdsecPreset } from '../data/crowdsecPresets' import { useConsoleStatus, useEnrollConsole, useClearConsoleEnrollment } from '../hooks/useConsoleEnrollment' import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../hooks/useCrowdSecWhitelist' import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport' +import { sanitizeKeptFileName } from '../utils/keptFileNames' import { sanitizeSecret } from '../utils/sanitizeSecret' import { toast } from '../utils/toast' @@ -45,6 +46,7 @@ export default function CrowdSecConfig() { const [file, setFile] = useState(null) // Hub items the last import reported as missing; the key restarts the panel per import. const [importHubItems, setImportHubItems] = useState<{ key: number; items: ImportHubItems } | null>(null) + const [keptFiles, setKeptFiles] = useState([]) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState(null) const [selectedPresetSlug, setSelectedPresetSlug] = useState('') @@ -126,6 +128,7 @@ export default function CrowdSecConfig() { toast.success(t('crowdsecConfig.importSuccess')) const items = result.hub_items setImportHubItems(items && items.missing.length > 0 ? { key: Date.now(), items } : null) + setKeptFiles(result.kept_files ?? []) queryClient.invalidateQueries({ queryKey: ['security-status'] }) }, onError: (err: unknown) => { @@ -138,11 +141,11 @@ export default function CrowdSecConfig() { const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { - toast.success('File saved') + toast.success(t('crowdsecConfig.toasts.fileSaved')) queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) }, onError: (err: unknown) => { - toast.error(getServerErrorMessage(err, 'Failed to save file')) + toast.error(getServerErrorMessage(err, t('crowdsecConfig.toasts.saveFailed'))) }, }) @@ -257,7 +260,7 @@ export default function CrowdSecConfig() { onSuccess: (data) => { setPresetPreview(data.preview) setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source }) - setPresetStatusMessage('Preview fetched from hub') + setPresetStatusMessage(t('crowdsecConfig.presets.status.previewFetched')) setHubUnavailable(false) setValidationError(null) }, @@ -265,17 +268,17 @@ export default function CrowdSecConfig() { setPresetStatusMessage(null) if (isAxiosError(err)) { if (err.response?.status === 400) { - setValidationError(err.response.data?.error || 'Preset slug is invalid') + setValidationError(err.response.data?.error || t('crowdsecConfig.presets.status.slugInvalid')) return } if (err.response?.status === 503) { setHubUnavailable(true) - setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.') + setPresetStatusMessage(t('crowdsecConfig.presets.status.hubUnavailable')) return } - setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview') + setPresetStatusMessage(err.response?.data?.error || t('crowdsecConfig.presets.status.pullFailed')) } else { - setPresetStatusMessage('Failed to pull preset preview') + setPresetStatusMessage(t('crowdsecConfig.presets.status.pullFailed')) } }, }) @@ -302,10 +305,10 @@ export default function CrowdSecConfig() { const cached = await getCrowdsecPresetCache(selectedPreset.slug) setPresetPreview(cached.preview) setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source }) - setPresetStatusMessage('Loaded cached preview') + setPresetStatusMessage(t('crowdsecConfig.presets.status.cachedLoaded')) setHubUnavailable(false) } catch (err) { - const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview' + const msg = isAxiosError(err) ? err.response?.data?.error || err.message : t('crowdsecConfig.presets.status.cacheLoadFailed') toast.error(msg) } } @@ -315,7 +318,7 @@ export default function CrowdSecConfig() { const isConsolePending = enrollConsoleMutation.isPending || normalizedConsoleStatus === 'enrolling' const isConsolePendingAcceptance = normalizedConsoleStatus === 'pending_acceptance' const consoleStatusLabel = normalizedConsoleStatus.replace('_', ' ') - const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? 'Stored (masked)' : 'Not stored') : '—' + const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? t('crowdsecConfig.consoleEnrollment.tokenStored') : t('crowdsecConfig.consoleEnrollment.tokenNotStored')) : '—' const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded' || isConsolePendingAcceptance const consoleDocsHref = 'https://wikid82.github.io/charon/security/' @@ -324,19 +327,19 @@ export default function CrowdSecConfig() { return sanitizeSecret(err.response?.data?.error || err.message) } if (err instanceof Error) return sanitizeSecret(err.message) - return 'Console enrollment failed' + return t('crowdsecConfig.consoleEnrollment.errors.enrollFailed') } const validateConsoleEnrollment = (options?: { requireAck?: boolean }) => { const nextErrors: { token?: string; agent?: string; ack?: string } = {} if (!enrollmentToken.trim()) { - nextErrors.token = 'Enrollment token is required' + nextErrors.token = t('crowdsecConfig.consoleEnrollment.tokenRequired') } if (!consoleAgentName.trim()) { - nextErrors.agent = 'Agent name is required' + nextErrors.agent = t('crowdsecConfig.consoleEnrollment.errors.agentRequired') } if (options?.requireAck && !consoleAck) { - nextErrors.ack = 'You must acknowledge the console data-sharing notice' + nextErrors.ack = t('crowdsecConfig.consoleEnrollment.errors.ackRequired') } setConsoleErrors(nextErrors) return Object.keys(nextErrors).length === 0 @@ -361,8 +364,8 @@ export default function CrowdSecConfig() { } toast.success( force - ? 'Enrollment submitted! Accept the request on app.crowdsec.net to complete.' - : 'Enrollment request sent! Accept the enrollment on app.crowdsec.net to complete registration.' + ? t('crowdsecConfig.consoleEnrollment.enrollSubmitted') + : t('crowdsecConfig.consoleEnrollment.enrollSent') ) } catch (err) { const message = sanitizeErrorMessage(err) @@ -381,25 +384,25 @@ export default function CrowdSecConfig() { const banMutation = useMutation({ mutationFn: () => banIP(banForm.ip, banForm.duration, banForm.reason), onSuccess: () => { - toast.success(`IP ${banForm.ip} has been banned`) + toast.success(t('crowdsecConfig.toasts.ipBanned', { ip: banForm.ip })) queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) setShowBanModal(false) setBanForm({ ip: '', duration: '24h', reason: '' }) }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to ban IP') + toast.error(err instanceof Error ? err.message : t('crowdsecConfig.toasts.banFailed')) }, }) const unbanMutation = useMutation({ mutationFn: (ip: string) => unbanIP(ip), onSuccess: (_, ip) => { - toast.success(`IP ${ip} has been unbanned`) + toast.success(t('crowdsecConfig.toasts.ipUnbanned', { ip })) queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) setConfirmUnban(null) }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to unban IP') + toast.error(err instanceof Error ? err.message : t('crowdsecConfig.toasts.unbanFailed')) }, }) @@ -410,7 +413,7 @@ export default function CrowdSecConfig() { const whitelistInlineError = addWhitelistMutation.error instanceof Error ? addWhitelistMutation.error.message : addWhitelistMutation.error != null - ? 'Failed to add entry' + ? t('crowdsecConfig.whitelist.addFailed') : null const handleAddMyIP = async () => { @@ -418,7 +421,7 @@ export default function CrowdSecConfig() { const result = await getMyIP() setWhitelistForm((prev) => ({ ...prev, ip_or_cidr: result.ip })) } catch { - toast.error('Failed to detect your IP address') + toast.error(t('crowdsecConfig.toasts.ipDetectFailed')) } } @@ -464,7 +467,7 @@ export default function CrowdSecConfig() { const handleApplyPreset = async () => { if (!selectedPreset) { - toast.error('Select a preset to apply') + toast.error(t('crowdsecConfig.toasts.selectPreset')) return } @@ -482,25 +485,24 @@ export default function CrowdSecConfig() { }) if (res.status !== 'applied') { - toast.error(`Preset apply did not complete (status: ${res.status || 'unknown'})`) + toast.error(t('crowdsecConfig.toasts.presetNotCompleted', { status: res.status || t('crowdsecConfig.toasts.statusUnknown') })) return } - const reloadNote = res.reload_hint ? ' (reload required)' : '' - toast.success(`Preset applied via backend${reloadNote}`) + toast.success(t(res.reload_hint ? 'crowdsecConfig.toasts.presetAppliedReload' : 'crowdsecConfig.toasts.presetApplied')) if (res.backup) { setPresetStatusMessage(backupCreatedMessage(res.backup)) } } catch (err) { if (!isAxiosError(err)) { - toast.error('Failed to apply preset') + toast.error(t('crowdsecConfig.toasts.presetApplyFailed')) return } // The server is authoritative for every preset source: surface its message for any // non-2xx response and never write preset content client-side. const status = err.response?.status const backupName = (err.response?.data as { backup?: string } | undefined)?.backup - const message = getServerErrorMessage(err, 'Failed to apply preset') + const message = getServerErrorMessage(err, t('crowdsecConfig.toasts.presetApplyFailed')) if (status === 400) { setValidationError(message) } @@ -508,7 +510,11 @@ export default function CrowdSecConfig() { setHubUnavailable(true) } setApplyInfo({ status: 'failed', backup: backupName || undefined, cacheKey: presetMeta?.cacheKey }) - toast.error(`Apply failed: ${message}${backupName ? `. ${backupCreatedMessage(backupName)}` : ''}`) + toast.error( + backupName + ? t('crowdsecConfig.toasts.applyFailedWithBackup', { message, backup: backupCreatedMessage(backupName) }) + : t('crowdsecConfig.toasts.applyFailed', { message }) + ) } finally { setIsApplyingPreset(false) } @@ -536,30 +542,30 @@ export default function CrowdSecConfig() { // Determine contextual message const getMessage = () => { if (pullPresetMutation.isPending) { - return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' } + return { message: t('crowdsecConfig.overlay.fetchingPreset'), submessage: t('crowdsecConfig.overlay.fetchingPresetDetail') } } if (isApplyingPreset) { - return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' } + return { message: t('crowdsecConfig.overlay.loadingPreset'), submessage: t('crowdsecConfig.overlay.loadingPresetDetail') } } if (importMutation.isPending) { - return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' } + return { message: t('crowdsecConfig.overlay.importing'), submessage: t('crowdsecConfig.overlay.importingDetail') } } if (writeMutation.isPending) { - return { message: 'Guardian inscribes...', submessage: 'Saving configuration file' } + return { message: t('crowdsecConfig.overlay.saving'), submessage: t('crowdsecConfig.overlay.savingDetail') } } if (banMutation.isPending) { - return { message: 'Guardian raises shield...', submessage: 'Banning IP address' } + return { message: t('crowdsecConfig.overlay.banning'), submessage: t('crowdsecConfig.overlay.banningDetail') } } if (unbanMutation.isPending) { - return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' } + return { message: t('crowdsecConfig.overlay.unbanning'), submessage: t('crowdsecConfig.overlay.unbanningDetail') } } if (addWhitelistMutation.isPending) { - return { message: 'Guardian updates list...', submessage: 'Adding IP to whitelist' } + return { message: t('crowdsecConfig.overlay.updatingList'), submessage: t('crowdsecConfig.overlay.whitelistAddingDetail') } } if (deleteWhitelistMutation.isPending) { - return { message: 'Guardian updates list...', submessage: 'Removing from whitelist' } + return { message: t('crowdsecConfig.overlay.updatingList'), submessage: t('crowdsecConfig.overlay.whitelistRemovingDetail') } } - return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' } + return { message: t('crowdsecConfig.overlay.default'), submessage: t('crowdsecConfig.overlay.defaultDetail') } } const { message, submessage } = getMessage() @@ -982,11 +988,17 @@ export default function CrowdSecConfig() { onChange={(e) => { setFile(e.target.files?.[0] ?? null) setImportHubItems(null) + setKeptFiles([]) }} data-testid="import-file" accept=".tar.gz" aria-label={t('crowdsecConfig.packages.selectFile')} /> + {keptFiles.length > 0 && ( +

+ {t('crowdsecConfig.importKeptFiles', { files: keptFiles.map(sanitizeKeptFileName).join(', ') })} +

+ )} {importHubItems && } diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.hubItems.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.hubItems.test.tsx index 0f99141c7..9108dcb15 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.hubItems.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.hubItems.test.tsx @@ -1,5 +1,6 @@ -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { AxiosError, type AxiosResponse } from 'axios' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as backupsApi from '../../api/backups' @@ -72,4 +73,81 @@ describe('CrowdSecConfig hub items panel', () => { expect(screen.queryByTestId('hub-items-panel')).not.toBeInTheDocument() }) + + it('shows the generic error and allows a retry when a restore answers 500 with a partial body', async () => { + await importWith({ status: 'imported', hub_items: { in_export: 2, skipped_invalid: 0, missing: MISSING } }) + const partialBody = { + error: 'the installed hub items could not be read; some items may already have been installed', + installed: [{ type: 'collections', name: 'crowdsecurity/caddy' }], + already_installed: [], + failed: [], + } + vi.mocked(crowdsecApi.restoreHubItems).mockRejectedValue( + new AxiosError('failed', 'ERR_BAD_RESPONSE', undefined, undefined, { + status: 500, + data: partialBody, + } as AxiosResponse), + ) + + await userEvent.click(await screen.findByRole('button', { name: 'Install missing hub items' })) + + expect(await screen.findByText('The hub items could not be installed. Try again.')).toBeInTheDocument() + expect(screen.queryByText(/installed, /i)).not.toBeInTheDocument() + expect(screen.getByTestId('hub-items-panel')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Install missing hub items' })).toBeEnabled() + }) +}) + +describe('CrowdSecConfig kept files note', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ + cerberus: { enabled: true }, + crowdsec: { enabled: true, mode: 'local', api_url: '' }, + waf: { enabled: true, mode: 'enabled' }, + rate_limit: { enabled: true }, + acl: { enabled: true }, + }) + vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1, lapi_ready: true }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] }) + vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] }) + vi.mocked(backupsApi.createBackup).mockResolvedValue({ job_id: 'j', type: 'create', status: 'pending' }) + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': false }) + }) + + it('lists the kept files as plain text in the import card', async () => { + await importWith({ status: 'imported', kept_files: ['acquis.yaml', 'notifications/x.yaml'] }) + + const note = await screen.findByTestId('import-kept-files') + expect(note).toHaveTextContent( + 'These files were not in the archive, so the current ones were kept: acquis.yaml, notifications/x.yaml', + ) + }) + + it('sanitises archive-derived names and does not render markup', async () => { + await importWith({ status: 'imported', kept_files: ['notifications/x\u202e\n.yaml'] }) + + const note = await screen.findByTestId('import-kept-files') + expect(note).toHaveTextContent('notifications/x.yaml') + expect(within(note).queryByText('x')).not.toBeInTheDocument() + }) + + it.each([ + ['no kept_files field', { status: 'imported' }], + ['an empty kept_files list', { status: 'imported', kept_files: [] }], + ])('shows no note with %s', async (_label, response) => { + await importWith(response) + + expect(screen.queryByTestId('import-kept-files')).not.toBeInTheDocument() + }) + + it('clears the note when another file is selected', async () => { + await importWith({ status: 'imported', kept_files: ['profiles.yaml'] }) + await screen.findByTestId('import-kept-files') + + await userEvent.upload(screen.getByTestId('import-file'), new File(['other'], 'other.tar.gz')) + + expect(screen.queryByTestId('import-kept-files')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.i18nKeys.test.ts b/frontend/src/pages/__tests__/CrowdSecConfig.i18nKeys.test.ts index c759d80fc..bc17e136b 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.i18nKeys.test.ts +++ b/frontend/src/pages/__tests__/CrowdSecConfig.i18nKeys.test.ts @@ -33,6 +33,29 @@ const usedKeys = (): string[] => { return [...keys] } +// Calls and assignments whose string arguments reach the user, so they must go through t(). +const USER_VISIBLE_CALL = /\b(?:toast\.(?:success|error|info|warning)|setPresetStatusMessage|setValidationError|getServerErrorMessage)\(/g +const USER_VISIBLE_LINE = /\b(?:sub)?message:|\bnextErrors\.\w+ =|\breturn '[A-Z]|^\s*[?:] '[A-Z]|\bmsg = .*'[A-Z]/ + +/** Returns the source of each user-visible call (up to its closing parenthesis) and each matching line. */ +const userVisibleCalls = (src: string): string[] => { + const calls: string[] = [] + for (const m of src.matchAll(USER_VISIBLE_CALL)) { + let depth = 0 + let end = m.index + for (; end < src.length; end++) { + if (src[end] === '(') depth++ + if (src[end] === ')' && --depth === 0) break + } + calls.push(src.slice(m.index, end + 1)) + } + return [...calls, ...src.split('\n').filter((line) => USER_VISIBLE_LINE.test(line))] +} + +/** True when the text still holds a quoted or template literal with letters after removing t('key') lookups. */ +const hasLiteralText = (code: string): boolean => + /(['`])[^'`]*[A-Za-z]{3}[^'`]*\1/.test(code.replace(/\bt\(\s*'[^']*'/g, 't(').replace(/\bt\(\s*[^'\s][^)]*\?[^)]*\)/g, 't(')) + describe('CrowdSec config i18n keys', () => { it('finds static keys in the sources', () => { expect(usedKeys().length).toBeGreaterThan(50) @@ -55,4 +78,11 @@ describe('CrowdSec config i18n keys', () => { expect(leafKeys(otherNode as Json, ns).sort()).toEqual(leafKeys(enNode as Json, ns).sort()) } }) + + it('CrowdSecConfig.tsx has no hard-coded user-visible strings in toasts, status messages or errors', () => { + const [source] = Object.entries(sourceModules).filter(([path]) => path.endsWith('/CrowdSecConfig.tsx')).map(([, src]) => src) + expect(source).toBeDefined() + expect(userVisibleCalls(source).length).toBeGreaterThan(20) + expect(userVisibleCalls(source).filter(hasLiteralText)).toEqual([]) + }) }) diff --git a/frontend/src/pages/__tests__/Notifications.test.tsx b/frontend/src/pages/__tests__/Notifications.test.tsx index fc7784505..5d41ea7e3 100644 --- a/frontend/src/pages/__tests__/Notifications.test.tsx +++ b/frontend/src/pages/__tests__/Notifications.test.tsx @@ -83,7 +83,7 @@ describe('Notifications', () => { beforeEach(() => { vi.clearAllMocks() setupMocks() - user = userEvent.setup() + user = userEvent.setup({ delay: null }) }) afterEach(() => { @@ -103,7 +103,7 @@ describe('Notifications', () => { }) it('rejects malformed URLs', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -116,7 +116,7 @@ describe('Notifications', () => { }) it('accepts a valid https URL', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -134,7 +134,7 @@ describe('Notifications', () => { }) it('accepts a valid http URL', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -152,7 +152,7 @@ describe('Notifications', () => { }) it('shows supported provider type options', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -166,7 +166,7 @@ describe('Notifications', () => { }) it('associates provider type label with select control', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -200,7 +200,7 @@ describe('Notifications', () => { it('shows and hides the update indicator after save', async () => { setupMocks([baseProvider]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId(`provider-row-${baseProvider.id}`) @@ -226,7 +226,7 @@ describe('Notifications', () => { setupMocks([baseProvider]) const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout') - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) const { unmount } = renderWithQueryClient() const row = await screen.findByTestId(`provider-row-${baseProvider.id}`) @@ -250,7 +250,7 @@ describe('Notifications', () => { } setupMocks([providerWithDisabledEvents]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId(`provider-row-${providerWithDisabledEvents.id}`) @@ -307,7 +307,7 @@ describe('Notifications', () => { vi.mocked(notificationsApi.deleteExternalTemplate).mockResolvedValue() const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' })) @@ -336,7 +336,7 @@ describe('Notifications', () => { }) it('shows security event subscription controls in provider form', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -347,7 +347,7 @@ describe('Notifications', () => { }) it('keeps add-provider guidance aligned with Discord webhook UX', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -358,7 +358,7 @@ describe('Notifications', () => { }) it('submits gotify token on create for gotify provider mode', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -387,7 +387,7 @@ describe('Notifications', () => { setupMocks([gotifyProvider]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId('provider-row-provider-gotify') @@ -414,7 +414,7 @@ describe('Notifications', () => { vi.mocked(notificationsApi.deleteExternalTemplate).mockResolvedValue() const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' })) @@ -460,7 +460,7 @@ describe('Notifications', () => { vi.mocked(notificationsApi.testProvider).mockResolvedValue() setupMocks([baseProvider]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId(`provider-row-${baseProvider.id}`) @@ -482,7 +482,7 @@ describe('Notifications', () => { parsed: undefined, }) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -516,7 +516,7 @@ describe('Notifications', () => { setupMocks([baseProvider]) vi.mocked(notificationsApi.testProvider).mockResolvedValue() - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId(`provider-row-${baseProvider.id}`) @@ -543,7 +543,7 @@ describe('Notifications', () => { setupMocks([gotifyProviderWithToken]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId('provider-row-provider-gotify-has-token') @@ -566,7 +566,7 @@ describe('Notifications', () => { setupMocks([gotifyProviderNoToken]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId('provider-row-provider-gotify-no-token') @@ -582,7 +582,7 @@ describe('Notifications', () => { vi.mocked(notificationsApi.testProvider).mockRejectedValue(new Error('Connection refused')) setupMocks([baseProvider]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId(`provider-row-${baseProvider.id}`) @@ -596,7 +596,7 @@ describe('Notifications', () => { }) it('disables test button when provider is new (unsaved) and not email type', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -605,7 +605,7 @@ describe('Notifications', () => { }) it('shows JSON template selector for gotify provider', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -615,7 +615,7 @@ describe('Notifications', () => { }) it('shows JSON template selector for webhook provider', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -625,7 +625,7 @@ describe('Notifications', () => { }) it('shows token field when slack type selected', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -635,7 +635,7 @@ describe('Notifications', () => { }) it('hides token field when switching from slack to discord', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -647,7 +647,7 @@ describe('Notifications', () => { }) it('submits slack provider with token as webhook URL', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -666,7 +666,7 @@ describe('Notifications', () => { }) it('does not require URL for slack', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -683,7 +683,7 @@ describe('Notifications', () => { }) it('renders pushover form with API Token field and User Key placeholder', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() await user.click(await screen.findByTestId('add-provider-btn')) @@ -704,7 +704,7 @@ describe('Notifications', () => { setupMocks([webpushProvider]) - const user = userEvent.setup() + const user = userEvent.setup({ delay: null }) renderWithQueryClient() const row = await screen.findByTestId('provider-row-provider-webpush') diff --git a/frontend/src/utils/__tests__/keptFileNames.test.ts b/frontend/src/utils/__tests__/keptFileNames.test.ts new file mode 100644 index 000000000..2c2ec03cc --- /dev/null +++ b/frontend/src/utils/__tests__/keptFileNames.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' + +import { sanitizeKeptFileName } from '../keptFileNames' + +describe('sanitizeKeptFileName', () => { + it('keeps ordinary names unchanged', () => { + expect(sanitizeKeptFileName('notifications/slack.yaml')).toBe('notifications/slack.yaml') + }) + + it('strips control, separator and direction-override characters', () => { + expect(sanitizeKeptFileName('a\u0000b\nc\u007fd\u0085e
f‮g⁦h.yaml')).toBe('abcdefgh.yaml') + }) + + it('caps long names', () => { + const result = sanitizeKeptFileName('x'.repeat(200)) + expect(result).toBe(`${'x'.repeat(80)}…`) + }) + + it('does not truncate a name of exactly the maximum length', () => { + expect(sanitizeKeptFileName('y'.repeat(80))).toBe('y'.repeat(80)) + }) +}) diff --git a/frontend/src/utils/keptFileNames.ts b/frontend/src/utils/keptFileNames.ts new file mode 100644 index 000000000..73921cb68 --- /dev/null +++ b/frontend/src/utils/keptFileNames.ts @@ -0,0 +1,13 @@ +const MAX_NAME_LENGTH = 80 + +// Control, format (including direction overrides) and line/paragraph separator characters. +const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu + +/** + * Makes an archive-derived file name safe to show as plain text: removes + * control and direction-override characters and caps the length. + */ +export const sanitizeKeptFileName = (name: string): string => { + const clean = name.replace(UNSAFE_CHARS, '') + return clean.length > MAX_NAME_LENGTH ? `${clean.slice(0, MAX_NAME_LENGTH)}…` : clean +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 362d8f415..74bfd44d9 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ url: 'http://localhost', }, }, + testTimeout: 15_000, + hookTimeout: 15_000, setupFiles: './src/test/setup.ts', // TypeScript types for test globals - these are automatically available in test files typecheck: { diff --git a/tests/security-enforcement/zzz-security-ui/crowdsec-import.spec.ts b/tests/security-enforcement/zzz-security-ui/crowdsec-import.spec.ts index 0c6ba39de..9aeb2dfa8 100644 --- a/tests/security-enforcement/zzz-security-ui/crowdsec-import.spec.ts +++ b/tests/security-enforcement/zzz-security-ui/crowdsec-import.spec.ts @@ -11,6 +11,7 @@ */ import { test, expect, loginUser } from '../../fixtures/auth-fixtures'; +import { cleanupBackupsCreatedSince } from '../../utils/backup-cleanup'; import { waitForLoadingComplete } from '../../utils/wait-helpers'; /** @@ -38,6 +39,15 @@ function createMockTarGzBuffer(): Buffer { } test.describe('Import CrowdSec Configuration', () => { + // The import UI creates a real application backup before each import (#1558). + let specStartedAt = ''; + test.beforeAll(() => { + specStartedAt = new Date().toISOString(); + }); + test.afterAll(async () => { + await cleanupBackupsCreatedSince(specStartedAt); + }); + // ========================================================================= // Page Layout Tests (2 tests) // ========================================================================= diff --git a/tests/security/crowdsec-diagnostics.spec.ts b/tests/security/crowdsec-diagnostics.spec.ts index d51029fb6..46167065c 100644 --- a/tests/security/crowdsec-diagnostics.spec.ts +++ b/tests/security/crowdsec-diagnostics.spec.ts @@ -40,7 +40,7 @@ labels: async function importKnownConfig( request: APIRequestContext, archivePath: string, - files: Record = { 'config.yaml': CONFIG_YAML, 'acquis.yaml': ACQUIS_YAML }, + files: Record = { 'config/config.yaml': CONFIG_YAML, 'config/acquis.yaml': ACQUIS_YAML }, ) { await createTarGz(files, archivePath); const response = await request.post(`${ADMIN}/import`, { @@ -83,23 +83,26 @@ test.describe('CrowdSec Diagnostics', () => { expect(config.errors[0]).toMatch(/^config\.yaml validation failed/); }); - test('should report missing files and an empty LAPI port when only config.yaml is imported', async ({ request }, testInfo) => { + test('should keep the current acquisition file and report an empty LAPI port when only config.yaml is imported', async ({ request }, testInfo) => { await importKnownConfig(request, testInfo.outputPath('config-only.tar.gz'), { - 'config.yaml': 'api:\n server:\n listen_uri: 0.0.0.0:8080\n', + 'config/config.yaml': 'api:\n server:\n listen_uri: 0.0.0.0:8080\n', }); const response = await request.get(`${ADMIN}/diagnostics/config`); expect(response.status()).toBe(200); const config = await response.json(); - expect(config).toMatchObject({ config_exists: true, acquis_exists: false, acquis_valid: false, lapi_port: '' }); - expect(config.errors).toContain('acquis.yaml not found'); + // The archive has no acquis.yaml, so the current one (from the known configuration) is kept. + expect(config).toMatchObject({ config_exists: true, acquis_exists: true, acquis_valid: true, lapi_port: '' }); + expect(config.errors).not.toContain('acquis.yaml not found'); + const acquis = await request.get(`${ADMIN}/file?path=${encodeURIComponent('config/acquis.yaml')}`); + expect((await acquis.json()).content).toBe(ACQUIS_YAML); }); test('should report an acquisition file without a datasource as invalid', async ({ request }, testInfo) => { await importKnownConfig(request, testInfo.outputPath('bad-acquis.tar.gz'), { - 'config.yaml': CONFIG_YAML, - 'acquis.yaml': 'labels:\n type: syslog\n', + 'config/config.yaml': CONFIG_YAML, + 'config/acquis.yaml': 'labels:\n type: syslog\n', }); const response = await request.get(`${ADMIN}/diagnostics/config`); @@ -162,9 +165,9 @@ test.describe('CrowdSec Diagnostics', () => { const registeredBefore = (await (await request.get(`${ADMIN}/diagnostics/connectivity`)).json()).capi_registered; await importKnownConfig(request, testInfo.outputPath('with-capi.tar.gz'), { - 'config.yaml': CONFIG_YAML, - 'acquis.yaml': ACQUIS_YAML, - 'online_api_credentials.yaml': 'url: https://api.crowdsec.net/\nlogin: e2e\npassword: e2e\n', + 'config/config.yaml': CONFIG_YAML, + 'config/acquis.yaml': ACQUIS_YAML, + 'config/online_api_credentials.yaml': 'url: https://api.crowdsec.net/\nlogin: e2e\npassword: e2e\n', }); const connectivity = await (await request.get(`${ADMIN}/diagnostics/connectivity`)).json(); @@ -207,11 +210,11 @@ test.describe('CrowdSec Diagnostics', () => { expect(response.status()).toBe(200); const { files } = await response.json(); - expect(files).toEqual(expect.arrayContaining(['config.yaml', 'acquis.yaml'])); + expect(files).toEqual(expect.arrayContaining(['config/config.yaml', 'config/acquis.yaml'])); }); test('should return the content of config.yaml', async ({ request }) => { - const response = await request.get(`${ADMIN}/file?path=${encodeURIComponent('config.yaml')}`); + const response = await request.get(`${ADMIN}/file?path=${encodeURIComponent('config/config.yaml')}`); expect(response.status()).toBe(200); expect((await response.json()).content).toBe(CONFIG_YAML); @@ -255,16 +258,17 @@ test.describe('CrowdSec Diagnostics', () => { expect(connectivity.lapi_ready).toBe(false); }); - test('should list every missing file as a string error', async ({ request }, testInfo) => { - await importKnownConfig(request, testInfo.outputPath('empty-acquis.tar.gz'), { - 'config.yaml': CONFIG_YAML, + test('should list every problem as a string error', async ({ request }, testInfo) => { + await importKnownConfig(request, testInfo.outputPath('no-datasource.tar.gz'), { + 'config/config.yaml': CONFIG_YAML, + 'config/acquis.yaml': 'labels:\n type: syslog\n', }); const config = await (await request.get(`${ADMIN}/diagnostics/config`)).json(); expect(config.errors).toEqual([ expect.stringMatching(/^config\.yaml validation failed/), - 'acquis.yaml not found', + "acquis.yaml missing datasource configuration (expected 'source:' and 'filenames:' or 'filename:')", ]); }); }); diff --git a/tests/security/crowdsec-hub-items.spec.ts b/tests/security/crowdsec-hub-items.spec.ts index 3f08eaa84..dbf36e1b4 100644 --- a/tests/security/crowdsec-hub-items.spec.ts +++ b/tests/security/crowdsec-hub-items.spec.ts @@ -15,6 +15,7 @@ import type { APIRequestContext, Page, Route, TestInfo } from '@playwright/test' import { request as playwrightRequest } from '@playwright/test'; import { test, expect, loginUser } from '../fixtures/auth-fixtures'; import { createTarGz } from '../utils/archive-helpers'; +import { cleanupBackupsCreatedSince } from '../utils/backup-cleanup'; import { waitForLoadingComplete } from '../utils/wait-helpers'; const ADMIN = '/api/v1/admin/crowdsec'; @@ -41,6 +42,15 @@ async function restore(request: APIRequestContext, data: unknown) { } test.describe('CrowdSec Hub Item Restore @security', () => { + // The import UI creates a real application backup before each import (#1558). + let specStartedAt = ''; + test.beforeAll(() => { + specStartedAt = new Date().toISOString(); + }); + test.afterAll(async () => { + await cleanupBackupsCreatedSince(specStartedAt); + }); + test.describe('Restore Validation', () => { const INVALID_NAMES: Array<{ title: string; name: string }> = [ { title: 'a flag-like name', name: '--help' }, diff --git a/tests/security/crowdsec-import.spec.ts b/tests/security/crowdsec-import.spec.ts index 49e844cb8..a3d53bf56 100644 --- a/tests/security/crowdsec-import.spec.ts +++ b/tests/security/crowdsec-import.spec.ts @@ -9,6 +9,9 @@ * - Rollback: a rejected import leaves the previous configuration untouched * * The result of an import is read back through the configuration file API. + * + * Every archive uses the real layout: a CrowdSec installation reads its configuration from the + * config/ folder, and so does every export. cfg() builds the path the file API lists and reads. */ import { promises as fs } from 'fs'; @@ -55,6 +58,25 @@ filenames: labels: type: syslog `; +/** The baseline carries the three companion files so every test starts from the same file set. */ +const BASELINE_PROFILES = `name: default_ip_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" +decisions: + - type: ban + duration: 4h +notifications: [] +on_success: break +`; +const BASELINE_SIMULATION = `simulation: false +# baseline-simulation +`; +/** The refusal message for an archive that keeps its configuration at the top level. */ +const FLAT_ARCHIVE_MESSAGE = + 'the archive keeps its configuration at the top level, but this CrowdSec installation reads it from the config folder; export a configuration from this page, or put the files in a config/ folder inside the archive'; + +/** The path of a configuration file as the file API lists and reads it. */ +const cfg = (name: string) => `config/${name}`; /** Uploads an archive file under the given upload name. */ async function upload(request: APIRequestContext, archivePath: string, uploadName: string, mimeType = 'application/gzip') { @@ -73,7 +95,12 @@ async function readConfigFile(request: APIRequestContext, name: string) { test.describe('CrowdSec Config Import Validation', () => { test.beforeEach(async ({ request }, testInfo) => { const baseline = await createTarGz( - { 'config.yaml': BASELINE_CONFIG, 'acquis.yaml': BASELINE_ACQUIS }, + { + 'config/config.yaml': BASELINE_CONFIG, + 'config/acquis.yaml': BASELINE_ACQUIS, + 'config/profiles.yaml': BASELINE_PROFILES, + 'config/simulation.yaml': BASELINE_SIMULATION, + }, testInfo.outputPath('baseline.tar.gz'), ); const response = await upload(request, baseline, 'baseline.tar.gz'); @@ -82,7 +109,7 @@ test.describe('CrowdSec Config Import Validation', () => { test.describe('Accepted Archives', () => { test('should import a valid archive and replace the configuration', async ({ request }, testInfo) => { - const archive = await createTarGz({ 'config.yaml': VALID_CONFIG }, testInfo.outputPath('valid.tar.gz')); + const archive = await createTarGz({ 'config/config.yaml': VALID_CONFIG }, testInfo.outputPath('valid.tar.gz')); const response = await upload(request, archive, 'valid.tar.gz'); @@ -91,12 +118,12 @@ test.describe('CrowdSec Config Import Validation', () => { expect(body.status).toBe('imported'); // The backup is reported by name only, never by an absolute path. expect(body.backup).toMatch(/^[^/\\]+$/); - expect(await readConfigFile(request, 'config.yaml')).toBe(VALID_CONFIG); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(VALID_CONFIG); }); test('should import optional files together with config.yaml', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, 'acquis.yaml': ACQUIS }, + { 'config/config.yaml': VALID_CONFIG, 'config/acquis.yaml': ACQUIS }, testInfo.outputPath('with-optional-files.tar.gz'), ); @@ -104,8 +131,8 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(200); expect((await response.json()).status).toBe('imported'); - expect(await readConfigFile(request, 'config.yaml')).toBe(VALID_CONFIG); - expect(await readConfigFile(request, 'acquis.yaml')).toBe(ACQUIS); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(VALID_CONFIG); + expect(await readConfigFile(request, cfg('acquis.yaml'))).toBe(ACQUIS); }); }); @@ -119,7 +146,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('should reject an archive missing config.yaml', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'other-file.txt': 'not a config', 'acquis.yaml': ACQUIS }, + { 'config/other-file.txt': 'not a config', 'config/acquis.yaml': ACQUIS }, testInfo.outputPath('no-config.tar.gz'), ); @@ -166,7 +193,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('should refuse an entry that escapes the configuration directory and keep the previous configuration', async ({ request }, testInfo) => { const archive = await createRawTarGz( [ - { name: 'config.yaml', content: VALID_CONFIG }, + { name: 'config/config.yaml', content: VALID_CONFIG }, { name: '../../etc/passwd', content: 'malicious content' }, ], testInfo.outputPath('path-traversal.tar.gz'), @@ -176,27 +203,27 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(500); expect((await response.json()).error).toBe('extraction failed'); - expect(await readConfigFile(request, 'config.yaml')).toBe(BASELINE_CONFIG); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(BASELINE_CONFIG); }); }); test.describe('Rollback', () => { test('should restore the previous configuration when the new config.yaml is rejected', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': 'other_config:\n field: value\n', 'acquis.yaml': ACQUIS }, + { 'config/config.yaml': 'other_config:\n field: value\n', 'config/acquis.yaml': ACQUIS }, testInfo.outputPath('bad-config.tar.gz'), ); const response = await upload(request, archive, 'bad-config.tar.gz'); expect(response.status()).toBe(422); - expect(await readConfigFile(request, 'config.yaml')).toBe(BASELINE_CONFIG); - expect(await readConfigFile(request, 'acquis.yaml')).toBe(BASELINE_ACQUIS); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(BASELINE_CONFIG); + expect(await readConfigFile(request, cfg('acquis.yaml'))).toBe(BASELINE_ACQUIS); }); test('should not leave files of the rejected archive behind', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': 'other_config:\n field: value\n', 'extra-from-bad-import.yaml': 'leftover: true\n' }, + { 'config/config.yaml': 'other_config:\n field: value\n', 'config/extra-from-bad-import.yaml': 'leftover: true\n' }, testInfo.outputPath('bad-extra.tar.gz'), ); @@ -204,8 +231,8 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(422); const files = (await (await request.get(`${ADMIN}/files`)).json()).files as string[]; - expect(files).toEqual(expect.arrayContaining(['config.yaml', 'acquis.yaml'])); - expect(files).not.toContain('extra-from-bad-import.yaml'); + expect(files).toEqual(expect.arrayContaining([cfg('config.yaml'), cfg('acquis.yaml')])); + expect(files).not.toContain(cfg('extra-from-bad-import.yaml')); }); }); @@ -218,7 +245,7 @@ test.describe('CrowdSec Config Import Validation', () => { const files = entries.filter((name) => !name.endsWith('/')); const baseNames = files.map((name) => name.split('/').pop()?.toLowerCase()); - expect(files).toEqual(expect.arrayContaining([expect.stringMatching(/^(config\/)?config\.yaml$/)])); + expect(files).toEqual(expect.arrayContaining(['config/config.yaml'])); // The hub item manifest is the one extra root entry, present exactly once. expect(files.filter((name) => name === HUB_MANIFEST_NAME)).toEqual([HUB_MANIFEST_NAME]); expect(files.filter((name) => /^(\.\/)?(data|hub_cache)\//.test(name))).toEqual([]); @@ -237,7 +264,7 @@ test.describe('CrowdSec Config Import Validation', () => { expect(Number(response.headers()['content-length'])).toBe(body.length); const entries = await listTarGzEntries(body); - expect(entries).toEqual(expect.arrayContaining([expect.stringMatching(/^(config\/)?config\.yaml$/)])); + expect(entries).toEqual(expect.arrayContaining(['config/config.yaml'])); expect(entries).toContain(HUB_MANIFEST_NAME); }); @@ -301,7 +328,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('import rejects a config.yaml that is not valid YAML even when it contains api: text', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': 'api:\n server: [unclosed\n bad indentation\n' }, + { 'config/config.yaml': 'api:\n server: [unclosed\n bad indentation\n' }, testInfo.outputPath('invalid-yaml-api.tar.gz'), ); @@ -313,7 +340,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('import rejects a config.yaml with no CrowdSec sections', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': 'other_config:\n field: value\n' }, + { 'config/config.yaml': 'other_config:\n field: value\n' }, testInfo.outputPath('no-sections.tar.gz'), ); @@ -345,13 +372,13 @@ test.describe('CrowdSec Config Import Validation', () => { ]; for (const { title, content, message } of configRejections) { test(`import rejects a config.yaml that ${title}`, async ({ request }, testInfo) => { - const archive = await createTarGz({ 'config.yaml': content }, testInfo.outputPath('rejected-config.tar.gz')); + const archive = await createTarGz({ 'config/config.yaml': content }, testInfo.outputPath('rejected-config.tar.gz')); const response = await upload(request, archive, 'rejected-config.tar.gz'); expect(response.status()).toBe(422); expect((await response.json()).error).toBe(message); - expect(await readConfigFile(request, 'config.yaml')).toBe(BASELINE_CONFIG); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(BASELINE_CONFIG); }); } @@ -375,6 +402,108 @@ test.describe('CrowdSec Config Import Validation', () => { }); }); + // A CrowdSec installation reads config/config.yaml, so an + // archive with its configuration at the top level must be refused instead of replacing it. + test.describe('Archive Layout', () => { + test('refuses an archive that keeps config.yaml at the top level and changes nothing', async ({ request }, testInfo) => { + const archive = await createTarGz( + { 'config.yaml': VALID_CONFIG, 'acquis.yaml': ACQUIS }, + testInfo.outputPath('flat-layout.tar.gz'), + ); + const filesBefore = (await (await request.get(`${ADMIN}/files`)).json()).files as string[]; + + const response = await upload(request, archive, 'flat-layout.tar.gz'); + + expect(response.status()).toBe(422); + expect((await response.json()).error).toBe(FLAT_ARCHIVE_MESSAGE); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(BASELINE_CONFIG); + expect(await readConfigFile(request, cfg('acquis.yaml'))).toBe(BASELINE_ACQUIS); + expect((await (await request.get(`${ADMIN}/files`)).json()).files as string[]).toEqual(filesBefore); + }); + }); + + // An archive that omits profiles.yaml, simulation.yaml, acquis.yaml or patterns/ must not leave + // CrowdSec without them: the current files are kept and reported. + test.describe('Kept Files', () => { + test('keeps the current files an archive omits and reports them sorted', async ({ request }, testInfo) => { + const archive = await createTarGz( + { 'config/config.yaml': VALID_CONFIG, 'config/profiles.yaml': BASELINE_PROFILES.replace('default_ip_remediation', 'archive_profile') }, + testInfo.outputPath('keeps-companions.tar.gz'), + ); + + const response = await upload(request, archive, 'keeps-companions.tar.gz'); + + expect(response.status()).toBe(200); + // The archive's own profiles.yaml wins and is not listed. The E2E tree also carries the default patterns/ folder. + expect((await response.json()).kept_files).toEqual(['acquis.yaml', 'patterns', 'simulation.yaml']); + expect(await readConfigFile(request, cfg('profiles.yaml'))).toContain('archive_profile'); + expect(await readConfigFile(request, cfg('acquis.yaml'))).toBe(BASELINE_ACQUIS); + expect(await readConfigFile(request, cfg('simulation.yaml'))).toBe(BASELINE_SIMULATION); + }); + + test('keeps the current profiles.yaml when the archive has none', async ({ request }, testInfo) => { + const archive = await createTarGz({ 'config/config.yaml': VALID_CONFIG }, testInfo.outputPath('no-profiles-kept.tar.gz')); + + const response = await upload(request, archive, 'no-profiles-kept.tar.gz'); + + expect(response.status()).toBe(200); + expect((await response.json()).kept_files).toEqual(['acquis.yaml', 'patterns', 'profiles.yaml', 'simulation.yaml']); + expect(await readConfigFile(request, cfg('profiles.yaml'))).toBe(BASELINE_PROFILES); + }); + + test('gives back the notification file a kept profiles.yaml names', async ({ request }, testInfo) => { + // Step 1: make the running system's profile name a notification, with the file that defines it. + const withProfile = await createTarGz( + { + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['x']), + 'config/notifications/x.yaml': notificationYaml('x'), + }, + testInfo.outputPath('profile-with-notification.tar.gz'), + ); + expect((await upload(request, withProfile, 'profile-with-notification.tar.gz')).status()).toBe(200); + + // Step 2: a config-only archive keeps that profile, so the notification file it needs comes back. + const configOnly = await createTarGz({ 'config/config.yaml': VALID_CONFIG }, testInfo.outputPath('config-only.tar.gz')); + const response = await upload(request, configOnly, 'config-only.tar.gz'); + + expect(response.status()).toBe(200); + expect((await response.json()).kept_files).toEqual( + expect.arrayContaining(['profiles.yaml', 'notifications/x.yaml']), + ); + expect(await readConfigFile(request, cfg('profiles.yaml'))).toBe(profilesYaml(['x'])); + expect(await readConfigFile(request, cfg('notifications/x.yaml'))).toBe(notificationYaml('x')); + }); + + test('refuses an archive without profiles.yaml when the kept profile names a notification no file defines', async ({ request }, testInfo) => { + // Replace the running profile with one naming a notification whose file the archive replaces by another name. + const withProfile = await createTarGz( + { + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['x']), + 'config/notifications/x.yaml': notificationYaml('x'), + }, + testInfo.outputPath('profile-for-refusal.tar.gz'), + ); + expect((await upload(request, withProfile, 'profile-for-refusal.tar.gz')).status()).toBe(200); + const filesBefore = (await (await request.get(`${ADMIN}/files`)).json()).files as string[]; + + // The archive's own x.yaml defines another name, so the file the kept profile needs cannot come back under it. + const clashing = await createTarGz( + { 'config/config.yaml': VALID_CONFIG, 'config/notifications/x.yaml': notificationYaml('y') }, + testInfo.outputPath('clashing-notification.tar.gz'), + ); + const response = await upload(request, clashing, 'clashing-notification.tar.gz'); + + expect(response.status()).toBe(422); + expect((await response.json()).error).toBe( + 'the archive has no profiles.yaml, so the current one was kept, but it names notifications the archive has no files for: x', + ); + expect(await readConfigFile(request, cfg('notifications/x.yaml'))).toBe(notificationYaml('x')); + expect((await (await request.get(`${ADMIN}/files`)).json()).files as string[]).toEqual(filesBefore); + }); + }); + // An import whose profiles.yaml names a notification that no file defines would leave CrowdSec // unable to start, so it is refused before anything changes. test.describe('Notification References', () => { @@ -383,7 +512,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('refuses a profiles.yaml naming a notification no file defines and changes nothing', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, 'profiles.yaml': profilesYaml(['x']) }, + { 'config/config.yaml': VALID_CONFIG, 'config/profiles.yaml': profilesYaml(['x']) }, testInfo.outputPath('missing-notification.tar.gz'), ); const filesBefore = (await (await request.get(`${ADMIN}/files`)).json()).files as string[]; @@ -392,14 +521,14 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(422); expect((await response.json()).error).toBe(missingMessage('x')); - expect(await readConfigFile(request, 'config.yaml')).toBe(BASELINE_CONFIG); - expect(await readConfigFile(request, 'acquis.yaml')).toBe(BASELINE_ACQUIS); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(BASELINE_CONFIG); + expect(await readConfigFile(request, cfg('acquis.yaml'))).toBe(BASELINE_ACQUIS); expect((await (await request.get(`${ADMIN}/files`)).json()).files as string[]).toEqual(filesBefore); }); test('lists every missing notification name in sorted order', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, 'profiles.yaml': profilesYaml(['zeta', 'alpha']) }, + { 'config/config.yaml': VALID_CONFIG, 'config/profiles.yaml': profilesYaml(['zeta', 'alpha']) }, testInfo.outputPath('two-missing.tar.gz'), ); @@ -412,7 +541,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('lists at most ten missing names and counts the rest', async ({ request }, testInfo) => { const names = Array.from({ length: 12 }, (_, index) => `n${String(index + 1).padStart(2, '0')}`); const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, 'profiles.yaml': profilesYaml([...names].reverse()) }, + { 'config/config.yaml': VALID_CONFIG, 'config/profiles.yaml': profilesYaml([...names].reverse()) }, testInfo.outputPath('many-missing.tar.gz'), ); @@ -425,9 +554,9 @@ test.describe('CrowdSec Config Import Validation', () => { test('accepts a profiles.yaml whose notification is defined by a notifications file', async ({ request }, testInfo) => { const archive = await createTarGz( { - 'config.yaml': VALID_CONFIG, - 'profiles.yaml': profilesYaml(['x']), - 'notifications/x.yaml': notificationYaml('x'), + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['x']), + 'config/notifications/x.yaml': notificationYaml('x'), }, testInfo.outputPath('defined-notification.tar.gz'), ); @@ -436,15 +565,15 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(200); expect((await response.json()).status).toBe('imported'); - expect(await readConfigFile(request, 'notifications/x.yaml')).toBe(notificationYaml('x')); + expect(await readConfigFile(request, cfg('notifications/x.yaml'))).toBe(notificationYaml('x')); }); test('accepts a notification defined in a .yml file', async ({ request }, testInfo) => { const archive = await createTarGz( { - 'config.yaml': VALID_CONFIG, - 'profiles.yaml': profilesYaml(['x']), - 'notifications/x.yml': notificationYaml('x'), + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['x']), + 'config/notifications/x.yml': notificationYaml('x'), }, testInfo.outputPath('yml-notification.tar.gz'), ); @@ -458,9 +587,9 @@ test.describe('CrowdSec Config Import Validation', () => { test('matches by the name field, so a multi-document file defines every name in it', async ({ request }, testInfo) => { const archive = await createTarGz( { - 'config.yaml': VALID_CONFIG, - 'profiles.yaml': profilesYaml(['first', 'second']), - 'notifications/both.yaml': notificationYaml('first', 'second'), + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['first', 'second']), + 'config/notifications/both.yaml': notificationYaml('first', 'second'), }, testInfo.outputPath('multi-document.tar.gz'), ); @@ -474,9 +603,9 @@ test.describe('CrowdSec Config Import Validation', () => { test('refuses a name that only the file name matches', async ({ request }, testInfo) => { const archive = await createTarGz( { - 'config.yaml': VALID_CONFIG, - 'profiles.yaml': profilesYaml(['a']), - 'notifications/a.yaml': notificationYaml('b'), + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['a']), + 'config/notifications/a.yaml': notificationYaml('b'), }, testInfo.outputPath('file-name-match.tar.gz'), ); @@ -490,9 +619,9 @@ test.describe('CrowdSec Config Import Validation', () => { test('accepts a name defined under a different file name', async ({ request }, testInfo) => { const archive = await createTarGz( { - 'config.yaml': VALID_CONFIG, - 'profiles.yaml': profilesYaml(['b']), - 'notifications/a.yaml': notificationYaml('b'), + 'config/config.yaml': VALID_CONFIG, + 'config/profiles.yaml': profilesYaml(['b']), + 'config/notifications/a.yaml': notificationYaml('b'), }, testInfo.outputPath('name-field-match.tar.gz'), ); @@ -505,15 +634,15 @@ test.describe('CrowdSec Config Import Validation', () => { // CrowdSec only loads lowercase .yaml/.yml files directly inside notifications/. const ignoredDefinitions = [ - { title: 'a subdirectory', path: 'notifications/sub/s.yaml' }, - { title: 'a .txt file', path: 'notifications/s.txt' }, - { title: 'a .bak file', path: 'notifications/s.bak' }, - { title: 'an uppercase .YAML file', path: 'notifications/s.YAML' }, + { title: 'a subdirectory', path: 'config/notifications/sub/s.yaml' }, + { title: 'a .txt file', path: 'config/notifications/s.txt' }, + { title: 'a .bak file', path: 'config/notifications/s.bak' }, + { title: 'an uppercase .YAML file', path: 'config/notifications/s.YAML' }, ]; for (const { title, path } of ignoredDefinitions) { test(`refuses a notification defined only in ${title}`, async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, 'profiles.yaml': profilesYaml(['s']), [path]: notificationYaml('s') }, + { 'config/config.yaml': VALID_CONFIG, 'config/profiles.yaml': profilesYaml(['s']), [path]: notificationYaml('s') }, testInfo.outputPath('ignored-definition.tar.gz'), ); const filesBefore = (await (await request.get(`${ADMIN}/files`)).json()).files as string[]; @@ -522,24 +651,27 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(422); expect((await response.json()).error).toBe(missingMessage('s')); - expect(await readConfigFile(request, 'config.yaml')).toBe(BASELINE_CONFIG); + expect(await readConfigFile(request, cfg('config.yaml'))).toBe(BASELINE_CONFIG); expect((await (await request.get(`${ADMIN}/files`)).json()).files as string[]).toEqual(filesBefore); }); } test('accepts an archive whose profiles.yaml names nothing', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, 'profiles.yaml': 'name: default_ip_remediation\nnotifications: []\non_success: break\n' }, + { 'config/config.yaml': VALID_CONFIG, 'config/profiles.yaml': 'name: default_ip_remediation\nnotifications: []\non_success: break\n' }, testInfo.outputPath('no-notifications.tar.gz'), ); const response = await upload(request, archive, 'no-notifications.tar.gz'); expect(response.status()).toBe(200); + // The archive supplies its own profiles.yaml, so only the files it omits are kept. + expect((await response.json()).kept_files).toEqual(['acquis.yaml', 'patterns', 'simulation.yaml']); + expect(await readConfigFile(request, cfg('profiles.yaml'))).toContain('notifications: []'); }); test('accepts an archive with no profiles.yaml', async ({ request }, testInfo) => { - const archive = await createTarGz({ 'config.yaml': VALID_CONFIG }, testInfo.outputPath('no-profiles.tar.gz')); + const archive = await createTarGz({ 'config/config.yaml': VALID_CONFIG }, testInfo.outputPath('no-profiles.tar.gz')); const response = await upload(request, archive, 'no-profiles.tar.gz'); @@ -559,14 +691,14 @@ test.describe('CrowdSec Config Import Validation', () => { async function importWithManifest(request: APIRequestContext, manifest: string, outputPath: string) { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, [HUB_MANIFEST_NAME]: manifest }, + { 'config/config.yaml': VALID_CONFIG, [HUB_MANIFEST_NAME]: manifest }, outputPath, ); return upload(request, archive, 'with-manifest.tar.gz'); } test('an archive without a manifest imports with no hub_items field', async ({ request }, testInfo) => { - const archive = await createTarGz({ 'config.yaml': VALID_CONFIG }, testInfo.outputPath('legacy.tar.gz')); + const archive = await createTarGz({ 'config/config.yaml': VALID_CONFIG }, testInfo.outputPath('legacy.tar.gz')); const response = await upload(request, archive, 'legacy.tar.gz'); @@ -600,7 +732,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('reads a manifest spelled ./charon-hub-items.json and still does not write it', async ({ request }, testInfo) => { const archive = await createRawTarGz( [ - { name: 'config.yaml', content: VALID_CONFIG }, + { name: 'config/config.yaml', content: VALID_CONFIG }, { name: `./${HUB_MANIFEST_NAME}`, content: hubManifest(ITEMS) }, ], testInfo.outputPath('dot-slash-manifest.tar.gz'), @@ -647,7 +779,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('ignores the whole manifest when the archive carries it twice', async ({ request }, testInfo) => { const archive = await createRawTarGz( [ - { name: 'config.yaml', content: VALID_CONFIG }, + { name: 'config/config.yaml', content: VALID_CONFIG }, { name: HUB_MANIFEST_NAME, content: hubManifest(ITEMS) }, { name: HUB_MANIFEST_NAME, content: hubManifest(ITEMS.slice(0, 1)) }, ], @@ -662,7 +794,7 @@ test.describe('CrowdSec Config Import Validation', () => { test('extracts a nested file with the manifest name as an ordinary file', async ({ request }, testInfo) => { const archive = await createTarGz( - { 'config.yaml': VALID_CONFIG, [`nested/${HUB_MANIFEST_NAME}`]: hubManifest(ITEMS) }, + { 'config/config.yaml': VALID_CONFIG, [`config/nested/${HUB_MANIFEST_NAME}`]: hubManifest(ITEMS) }, testInfo.outputPath('nested-manifest.tar.gz'), ); @@ -670,7 +802,7 @@ test.describe('CrowdSec Config Import Validation', () => { expect(response.status()).toBe(200); expect(await response.json()).not.toHaveProperty('hub_items'); - expect(await readConfigFile(request, `nested/${HUB_MANIFEST_NAME}`)).toBe(hubManifest(ITEMS)); + expect(await readConfigFile(request, cfg(`nested/${HUB_MANIFEST_NAME}`))).toBe(hubManifest(ITEMS)); }); }); }); diff --git a/tests/utils/backup-cleanup.ts b/tests/utils/backup-cleanup.ts new file mode 100644 index 000000000..eb818c928 --- /dev/null +++ b/tests/utils/backup-cleanup.ts @@ -0,0 +1,72 @@ +/** + * Best-effort removal of application backups that a spec created. + * + * The CrowdSec import UI creates a real application backup (POST /api/v1/backups) before every + * import. On the local E2E container /app/data is a tmpfs, so repeated runs against one container + * fill it. Call `cleanupBackupsCreatedSince` from `test.afterAll` with the time the spec started. + * + * This is best effort: `afterAll` does not run when a worker is killed or the run is interrupted, + * so the larger /app/data tmpfs in docker-compose.playwright-local.yml remains the backstop. + */ +import { existsSync } from 'fs'; +import { request as playwrightRequest } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { STORAGE_STATE } from '../constants'; + +interface BackupEntry { + filename: string; + time: string; +} + +/** + * Deletes every backup whose timestamp is at or after `isoTime`. + * Never throws: cleanup problems must not fail a spec. + * + * @returns the number of backups removed + */ +export async function deleteBackupsCreatedSince( + request: APIRequestContext, + isoTime: string, +): Promise { + const since = Date.parse(isoTime); + let removed = 0; + try { + const list = await request.get('/api/v1/backups'); + if (!list.ok()) { + return 0; + } + const backups = (await list.json()) as BackupEntry[]; + for (const backup of backups) { + if (Date.parse(backup.time) < since) { + continue; + } + const response = await request.delete(`/api/v1/backups/${encodeURIComponent(backup.filename)}`); + if (response.ok()) { + removed++; + } + } + } catch { + // Best effort by design; see the file comment. + } + return removed; +} + +/** + * `afterAll` hooks have no test-scoped fixtures, so this opens its own admin API context from the + * stored auth state (the auth_token cookie authenticates it), runs `deleteBackupsCreatedSince`, and disposes the context. + */ +export async function cleanupBackupsCreatedSince(isoTime: string): Promise { + if (!existsSync(STORAGE_STATE)) { + return 0; + } + const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; + const context = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + }); + try { + return await deleteBackupsCreatedSince(context, isoTime); + } finally { + await context.dispose(); + } +}