github.com/crowdsecurity/crowdsec@v1.6.1/pkg/cwhub/sync.go (about)

     1  package cwhub
     2  
     3  import (
     4  	"crypto/sha256"
     5  	"encoding/hex"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path/filepath"
    10  	"slices"
    11  	"sort"
    12  	"strings"
    13  
    14  	"github.com/Masterminds/semver/v3"
    15  	"github.com/sirupsen/logrus"
    16  	"gopkg.in/yaml.v3"
    17  )
    18  
    19  func isYAMLFileName(path string) bool {
    20  	return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")
    21  }
    22  
    23  // linkTarget returns the target of a symlink, or empty string if it's dangling.
    24  func linkTarget(path string, logger *logrus.Logger) (string, error) {
    25  	hubpath, err := os.Readlink(path)
    26  	if err != nil {
    27  		return "", fmt.Errorf("unable to read symlink: %s", path)
    28  	}
    29  
    30  	logger.Tracef("symlink %s -> %s", path, hubpath)
    31  
    32  	_, err = os.Lstat(hubpath)
    33  	if os.IsNotExist(err) {
    34  		logger.Warningf("link target does not exist: %s -> %s", path, hubpath)
    35  		return "", nil
    36  	}
    37  
    38  	return hubpath, nil
    39  }
    40  
    41  func getSHA256(filepath string) (string, error) {
    42  	f, err := os.Open(filepath)
    43  	if err != nil {
    44  		return "", fmt.Errorf("unable to open '%s': %w", filepath, err)
    45  	}
    46  
    47  	defer f.Close()
    48  
    49  	h := sha256.New()
    50  	if _, err := io.Copy(h, f); err != nil {
    51  		return "", fmt.Errorf("unable to calculate sha256 of '%s': %w", filepath, err)
    52  	}
    53  
    54  	return hex.EncodeToString(h.Sum(nil)), nil
    55  }
    56  
    57  // information used to create a new Item, from a file path.
    58  type itemFileInfo struct {
    59  	inhub   bool
    60  	fname   string
    61  	stage   string
    62  	ftype   string
    63  	fauthor string
    64  }
    65  
    66  func (h *Hub) getItemFileInfo(path string, logger *logrus.Logger) (*itemFileInfo, error) {
    67  	var ret *itemFileInfo
    68  
    69  	hubDir := h.local.HubDir
    70  	installDir := h.local.InstallDir
    71  
    72  	subs := strings.Split(path, string(os.PathSeparator))
    73  
    74  	logger.Tracef("path:%s, hubdir:%s, installdir:%s", path, hubDir, installDir)
    75  	logger.Tracef("subs:%v", subs)
    76  	// we're in hub (~/.hub/hub/)
    77  	if strings.HasPrefix(path, hubDir) {
    78  		logger.Tracef("in hub dir")
    79  
    80  		// .../hub/parsers/s00-raw/crowdsec/skip-pretag.yaml
    81  		// .../hub/scenarios/crowdsec/ssh_bf.yaml
    82  		// .../hub/profiles/crowdsec/linux.yaml
    83  		if len(subs) < 4 {
    84  			return nil, fmt.Errorf("path is too short: %s (%d)", path, len(subs))
    85  		}
    86  
    87  		ret = &itemFileInfo{
    88  			inhub:   true,
    89  			fname:   subs[len(subs)-1],
    90  			fauthor: subs[len(subs)-2],
    91  			stage:   subs[len(subs)-3],
    92  			ftype:   subs[len(subs)-4],
    93  		}
    94  	} else if strings.HasPrefix(path, installDir) { // we're in install /etc/crowdsec/<type>/...
    95  		logger.Tracef("in install dir")
    96  
    97  		if len(subs) < 3 {
    98  			return nil, fmt.Errorf("path is too short: %s (%d)", path, len(subs))
    99  		}
   100  		// .../config/parser/stage/file.yaml
   101  		// .../config/postoverflow/stage/file.yaml
   102  		// .../config/scenarios/scenar.yaml
   103  		// .../config/collections/linux.yaml //file is empty
   104  		ret = &itemFileInfo{
   105  			inhub:   false,
   106  			fname:   subs[len(subs)-1],
   107  			stage:   subs[len(subs)-2],
   108  			ftype:   subs[len(subs)-3],
   109  			fauthor: "",
   110  		}
   111  	} else {
   112  		return nil, fmt.Errorf("file '%s' is not from hub '%s' nor from the configuration directory '%s'", path, hubDir, installDir)
   113  	}
   114  
   115  	logger.Tracef("stage:%s ftype:%s", ret.stage, ret.ftype)
   116  
   117  	if ret.ftype != PARSERS && ret.ftype != POSTOVERFLOWS {
   118  		if !slices.Contains(ItemTypes, ret.stage) {
   119  			return nil, fmt.Errorf("unknown configuration type for file '%s'", path)
   120  		}
   121  
   122  		ret.ftype = ret.stage
   123  		ret.stage = ""
   124  	}
   125  
   126  	logger.Tracef("CORRECTED [%s] by [%s] in stage [%s] of type [%s]", ret.fname, ret.fauthor, ret.stage, ret.ftype)
   127  
   128  	return ret, nil
   129  }
   130  
   131  // sortedVersions returns the input data, sorted in reverse order (new, old) by semver.
   132  func sortedVersions(raw []string) ([]string, error) {
   133  	vs := make([]*semver.Version, len(raw))
   134  
   135  	for idx, r := range raw {
   136  		v, err := semver.NewVersion(r)
   137  		if err != nil {
   138  			return nil, fmt.Errorf("%s: %w", r, err)
   139  		}
   140  
   141  		vs[idx] = v
   142  	}
   143  
   144  	sort.Sort(sort.Reverse(semver.Collection(vs)))
   145  
   146  	ret := make([]string, len(vs))
   147  	for idx, v := range vs {
   148  		ret[idx] = v.Original()
   149  	}
   150  
   151  	return ret, nil
   152  }
   153  
   154  func newLocalItem(h *Hub, path string, info *itemFileInfo) (*Item, error) {
   155  	type localItemName struct {
   156  		Name string `yaml:"name"`
   157  	}
   158  
   159  	_, fileName := filepath.Split(path)
   160  
   161  	item := &Item{
   162  		hub:      h,
   163  		Name:     info.fname,
   164  		Stage:    info.stage,
   165  		Type:     info.ftype,
   166  		FileName: fileName,
   167  		State: ItemState{
   168  			LocalPath: path,
   169  			Installed: true,
   170  			UpToDate:  true,
   171  		},
   172  	}
   173  
   174  	// try to read the name from the file
   175  	itemName := localItemName{}
   176  
   177  	itemContent, err := os.ReadFile(path)
   178  	if err != nil {
   179  		return nil, fmt.Errorf("failed to read %s: %w", path, err)
   180  	}
   181  
   182  	err = yaml.Unmarshal(itemContent, &itemName)
   183  	if err != nil {
   184  		return nil, fmt.Errorf("failed to unmarshal %s: %w", path, err)
   185  	}
   186  
   187  	if itemName.Name != "" {
   188  		item.Name = itemName.Name
   189  	}
   190  
   191  	return item, nil
   192  }
   193  
   194  func (h *Hub) itemVisit(path string, f os.DirEntry, err error) error {
   195  	hubpath := ""
   196  
   197  	if err != nil {
   198  		h.logger.Debugf("while syncing hub dir: %s", err)
   199  		// there is a path error, we ignore the file
   200  		return nil
   201  	}
   202  
   203  	// only happens if the current working directory was removed (!)
   204  	path, err = filepath.Abs(path)
   205  	if err != nil {
   206  		return err
   207  	}
   208  
   209  	// we only care about YAML files
   210  	if f == nil || f.IsDir() || !isYAMLFileName(f.Name()) {
   211  		return nil
   212  	}
   213  
   214  	info, err := h.getItemFileInfo(path, h.logger)
   215  	if err != nil {
   216  		return err
   217  	}
   218  
   219  	// non symlinks are local user files or hub files
   220  	if f.Type()&os.ModeSymlink == 0 {
   221  		h.logger.Tracef("%s is not a symlink", path)
   222  
   223  		if !info.inhub {
   224  			h.logger.Tracef("%s is a local file, skip", path)
   225  
   226  			item, err := newLocalItem(h, path, info)
   227  			if err != nil {
   228  				return err
   229  			}
   230  
   231  			h.addItem(item)
   232  
   233  			return nil
   234  		}
   235  	} else {
   236  		hubpath, err = linkTarget(path, h.logger)
   237  		if err != nil {
   238  			return err
   239  		}
   240  
   241  		if hubpath == "" {
   242  			// target does not exist, the user might have removed the file
   243  			// or switched to a hub branch without it
   244  			return nil
   245  		}
   246  	}
   247  
   248  	// try to find which configuration item it is
   249  	h.logger.Tracef("check [%s] of %s", info.fname, info.ftype)
   250  
   251  	for _, item := range h.GetItemMap(info.ftype) {
   252  		if info.fname != item.FileName {
   253  			continue
   254  		}
   255  
   256  		if item.Stage != info.stage {
   257  			continue
   258  		}
   259  
   260  		// if we are walking hub dir, just mark present files as downloaded
   261  		if info.inhub {
   262  			// wrong author
   263  			if info.fauthor != item.Author {
   264  				continue
   265  			}
   266  
   267  			// not the item we're looking for
   268  			if !item.validPath(info.fauthor, info.fname) {
   269  				continue
   270  			}
   271  
   272  			src, err := item.downloadPath()
   273  			if err != nil {
   274  				return err
   275  			}
   276  
   277  			if path == src {
   278  				h.logger.Tracef("marking %s as downloaded", item.Name)
   279  				item.State.Downloaded = true
   280  			}
   281  		} else if !hasPathSuffix(hubpath, item.RemotePath) {
   282  			// wrong file
   283  			// <type>/<stage>/<author>/<name>.yaml
   284  			continue
   285  		}
   286  
   287  		err := item.setVersionState(path, info.inhub)
   288  		if err != nil {
   289  			return err
   290  		}
   291  
   292  		return nil
   293  	}
   294  
   295  	h.logger.Infof("Ignoring file %s of type %s", path, info.ftype)
   296  
   297  	return nil
   298  }
   299  
   300  // checkSubItemVersions checks for the presence, taint and version state of sub-items.
   301  func (i *Item) checkSubItemVersions() []string {
   302  	warn := make([]string, 0)
   303  
   304  	if !i.HasSubItems() {
   305  		return warn
   306  	}
   307  
   308  	if i.versionStatus() != versionUpToDate {
   309  		i.hub.logger.Debugf("%s dependencies not checked: not up-to-date", i.Name)
   310  		return warn
   311  	}
   312  
   313  	// ensure all the sub-items are installed, or tag the parent as tainted
   314  	i.hub.logger.Tracef("checking submembers of %s installed:%t", i.Name, i.State.Installed)
   315  
   316  	for _, sub := range i.SubItems() {
   317  		i.hub.logger.Tracef("check %s installed:%t", sub.Name, sub.State.Installed)
   318  
   319  		if !i.State.Installed {
   320  			continue
   321  		}
   322  
   323  		if w := sub.checkSubItemVersions(); len(w) > 0 {
   324  			if sub.State.Tainted {
   325  				i.addTaint(sub)
   326  				warn = append(warn, fmt.Sprintf("%s is tainted by %s", i.Name, sub.FQName()))
   327  			}
   328  
   329  			warn = append(warn, w...)
   330  
   331  			continue
   332  		}
   333  
   334  		if sub.State.Tainted {
   335  			i.addTaint(sub)
   336  			warn = append(warn, fmt.Sprintf("%s is tainted by %s", i.Name, sub.FQName()))
   337  
   338  			continue
   339  		}
   340  
   341  		if !sub.State.Installed && i.State.Installed {
   342  			i.addTaint(sub)
   343  			warn = append(warn, fmt.Sprintf("%s is tainted by missing %s", i.Name, sub.FQName()))
   344  
   345  			continue
   346  		}
   347  
   348  		if !sub.State.UpToDate {
   349  			i.State.UpToDate = false
   350  			warn = append(warn, fmt.Sprintf("%s is tainted by outdated %s", i.Name, sub.FQName()))
   351  
   352  			continue
   353  		}
   354  
   355  		i.hub.logger.Tracef("checking for %s - tainted:%t uptodate:%t", sub.Name, i.State.Tainted, i.State.UpToDate)
   356  	}
   357  
   358  	return warn
   359  }
   360  
   361  // syncDir scans a directory for items, and updates the Hub state accordingly.
   362  func (h *Hub) syncDir(dir string) error {
   363  	// For each, scan PARSERS, POSTOVERFLOWS... and COLLECTIONS last
   364  	for _, scan := range ItemTypes {
   365  		// cpath: top-level item directory, either downloaded or installed items.
   366  		// i.e. /etc/crowdsec/parsers, /etc/crowdsec/hub/parsers, ...
   367  		cpath, err := filepath.Abs(fmt.Sprintf("%s/%s", dir, scan))
   368  		if err != nil {
   369  			h.logger.Errorf("failed %s: %s", cpath, err)
   370  			continue
   371  		}
   372  
   373  		// explicit check for non existing directory, avoid spamming log.Debug
   374  		if _, err = os.Stat(cpath); os.IsNotExist(err) {
   375  			h.logger.Tracef("directory %s doesn't exist, skipping", cpath)
   376  			continue
   377  		}
   378  
   379  		if err = filepath.WalkDir(cpath, h.itemVisit); err != nil {
   380  			return err
   381  		}
   382  	}
   383  
   384  	return nil
   385  }
   386  
   387  // insert a string in a sorted slice, case insensitive, and return the new slice.
   388  func insertInOrderNoCase(sl []string, value string) []string {
   389  	i := sort.Search(len(sl), func(i int) bool {
   390  		return strings.ToLower(sl[i]) >= strings.ToLower(value)
   391  	})
   392  
   393  	return append(sl[:i], append([]string{value}, sl[i:]...)...)
   394  }
   395  
   396  func removeDuplicates(sl []string) []string {
   397  	seen := make(map[string]struct{}, len(sl))
   398  	j := 0
   399  
   400  	for _, v := range sl {
   401  		if _, ok := seen[v]; ok {
   402  			continue
   403  		}
   404  
   405  		seen[v] = struct{}{}
   406  		sl[j] = v
   407  		j++
   408  	}
   409  
   410  	return sl[:j]
   411  }
   412  
   413  // localSync updates the hub state with downloaded, installed and local items.
   414  func (h *Hub) localSync() error {
   415  	err := h.syncDir(h.local.InstallDir)
   416  	if err != nil {
   417  		return fmt.Errorf("failed to scan %s: %w", h.local.InstallDir, err)
   418  	}
   419  
   420  	if err = h.syncDir(h.local.HubDir); err != nil {
   421  		return fmt.Errorf("failed to scan %s: %w", h.local.HubDir, err)
   422  	}
   423  
   424  	warnings := make([]string, 0)
   425  
   426  	for _, item := range h.GetItemMap(COLLECTIONS) {
   427  		// check for cyclic dependencies
   428  		subs, err := item.descendants()
   429  		if err != nil {
   430  			return err
   431  		}
   432  
   433  		// populate the sub- and sub-sub-items with the collections they belong to
   434  		for _, sub := range subs {
   435  			sub.State.BelongsToCollections = insertInOrderNoCase(sub.State.BelongsToCollections, item.Name)
   436  		}
   437  
   438  		if !item.State.Installed {
   439  			continue
   440  		}
   441  
   442  		vs := item.versionStatus()
   443  		switch vs {
   444  		case versionUpToDate: // latest
   445  			if w := item.checkSubItemVersions(); len(w) > 0 {
   446  				warnings = append(warnings, w...)
   447  			}
   448  		case versionUpdateAvailable: // not up-to-date
   449  			warnings = append(warnings, fmt.Sprintf("update for collection %s available (currently:%s, latest:%s)", item.Name, item.State.LocalVersion, item.Version))
   450  		case versionFuture:
   451  			warnings = append(warnings, fmt.Sprintf("collection %s is in the future (currently:%s, latest:%s)", item.Name, item.State.LocalVersion, item.Version))
   452  		case versionUnknown:
   453  			if !item.State.IsLocal() {
   454  				warnings = append(warnings, fmt.Sprintf("collection %s is tainted by local changes (latest:%s)", item.Name, item.Version))
   455  			}
   456  		}
   457  
   458  		h.logger.Debugf("installed (%s) - status: %d | installed: %s | latest: %s | full: %+v", item.Name, vs, item.State.LocalVersion, item.Version, item.Versions)
   459  	}
   460  
   461  	h.Warnings = removeDuplicates(warnings)
   462  
   463  	return nil
   464  }
   465  
   466  func (i *Item) setVersionState(path string, inhub bool) error {
   467  	var err error
   468  
   469  	i.State.LocalHash, err = getSHA256(path)
   470  	if err != nil {
   471  		return fmt.Errorf("failed to get sha256 of %s: %w", path, err)
   472  	}
   473  
   474  	// let's reverse sort the versions to deal with hash collisions (#154)
   475  	versions := make([]string, 0, len(i.Versions))
   476  	for k := range i.Versions {
   477  		versions = append(versions, k)
   478  	}
   479  
   480  	versions, err = sortedVersions(versions)
   481  	if err != nil {
   482  		return fmt.Errorf("while syncing %s %s: %w", i.Type, i.FileName, err)
   483  	}
   484  
   485  	i.State.LocalVersion = "?"
   486  
   487  	for _, version := range versions {
   488  		if i.Versions[version].Digest == i.State.LocalHash {
   489  			i.State.LocalVersion = version
   490  			break
   491  		}
   492  	}
   493  
   494  	if i.State.LocalVersion == "?" {
   495  		i.hub.logger.Tracef("got tainted match for %s: %s", i.Name, path)
   496  
   497  		if !inhub {
   498  			i.State.LocalPath = path
   499  			i.State.Installed = true
   500  		}
   501  
   502  		i.State.UpToDate = false
   503  		i.addTaint(i)
   504  
   505  		return nil
   506  	}
   507  
   508  	// we got an exact match, update struct
   509  
   510  	i.State.Downloaded = true
   511  
   512  	if !inhub {
   513  		i.hub.logger.Tracef("found exact match for %s, version is %s, latest is %s", i.Name, i.State.LocalVersion, i.Version)
   514  		i.State.LocalPath = path
   515  		i.State.Tainted = false
   516  		// if we're walking the hub, present file doesn't means installed file
   517  		i.State.Installed = true
   518  	}
   519  
   520  	if i.State.LocalVersion == i.Version {
   521  		i.hub.logger.Tracef("%s is up-to-date", i.Name)
   522  		i.State.UpToDate = true
   523  	}
   524  
   525  	return nil
   526  }