crowdsec/pkg/setup/detect.go
mmetc b6be18ca65
cscli setup (#1923)
Detect running services and generate acquisition configuration
2023-02-06 07:33:04 +01:00

581 lines
14 KiB
Go

package setup
import (
"bytes"
"fmt"
"os"
"os/exec"
"sort"
"github.com/Masterminds/semver"
"github.com/antonmedv/expr"
"github.com/blackfireio/osinfo"
"github.com/shirou/gopsutil/v3/process"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
// goccyyaml "github.com/goccy/go-yaml"
// "github.com/k0kubun/pp"
"github.com/crowdsecurity/crowdsec/pkg/acquisition"
"github.com/crowdsecurity/crowdsec/pkg/acquisition/configuration"
)
// ExecCommand can be replaced with a mock during tests.
var ExecCommand = exec.Command
// HubItems contains the objects that are recommended to support a service.
type HubItems struct {
Collections []string `yaml:"collections,omitempty"`
Parsers []string `yaml:"parsers,omitempty"`
Scenarios []string `yaml:"scenarios,omitempty"`
PostOverflows []string `yaml:"postoverflows,omitempty"`
}
type DataSourceItem map[string]interface{}
// ServiceSetup describes the recommendations (hub objects and datasources) for a detected service.
type ServiceSetup struct {
DetectedService string `yaml:"detected_service"`
Install *HubItems `yaml:"install,omitempty"`
DataSource DataSourceItem `yaml:"datasource,omitempty"`
}
// Setup is a container for a list of ServiceSetup objects, allowing for future extensions.
type Setup struct {
Setup []ServiceSetup `yaml:"setup"`
}
func validateDataSource(opaqueDS DataSourceItem) error {
if len(opaqueDS) == 0 {
// empty datasource is valid
return nil
}
// formally validate YAML
commonDS := configuration.DataSourceCommonCfg{}
body, err := yaml.Marshal(opaqueDS)
if err != nil {
return err
}
err = yaml.Unmarshal(body, &commonDS)
if err != nil {
return err
}
// source is mandatory // XXX unless it's not?
if commonDS.Source == "" {
return fmt.Errorf("source is empty")
}
// source must be known
ds := acquisition.GetDataSourceIface(commonDS.Source)
if ds == nil {
return fmt.Errorf("unknown source '%s'", commonDS.Source)
}
// unmarshal and validate the rest with the specific implementation
err = ds.UnmarshalConfig(body)
if err != nil {
return err
}
// pp.Println(ds)
return nil
}
func readDetectConfig(file string) (DetectConfig, error) {
var dc DetectConfig
yamlBytes, err := os.ReadFile(file)
if err != nil {
return DetectConfig{}, fmt.Errorf("while reading file: %w", err)
}
dec := yaml.NewDecoder(bytes.NewBuffer(yamlBytes))
dec.KnownFields(true)
if err = dec.Decode(&dc); err != nil {
return DetectConfig{}, fmt.Errorf("while parsing %s: %w", file, err)
}
switch dc.Version {
case "":
return DetectConfig{}, fmt.Errorf("missing version tag (must be 1.0)")
case "1.0":
// all is well
default:
return DetectConfig{}, fmt.Errorf("unsupported version tag '%s' (must be 1.0)", dc.Version)
}
for name, svc := range dc.Detect {
err = validateDataSource(svc.DataSource)
if err != nil {
return DetectConfig{}, fmt.Errorf("invalid datasource for %s: %w", name, err)
}
}
return dc, nil
}
// Service describes the rules for detecting a service and its recommended items.
type Service struct {
When []string `yaml:"when"`
Install *HubItems `yaml:"install,omitempty"`
DataSource DataSourceItem `yaml:"datasource,omitempty"`
// AcquisYAML []byte
}
// DetectConfig is the container of all detection rules (detect.yaml).
type DetectConfig struct {
Version string `yaml:"version"`
Detect map[string]Service `yaml:"detect"`
}
// ExprState keeps a global state for the duration of the service detection (cache etc.)
type ExprState struct {
unitsSearched map[string]bool
detectOptions DetectOptions
// cache
installedUnits map[string]bool
// true if the list of running processes has already been retrieved, we can
// avoid getting it a second time.
processesSearched map[string]bool
// cache
runningProcesses map[string]bool
}
// ExprServiceState keep a local state during the detection of a single service. It is reset before each service rules' evaluation.
type ExprServiceState struct {
detectedUnits []string
}
// ExprOS contains the detected (or forced) OS fields available to the rule engine.
type ExprOS struct {
Family string
ID string
RawVersion string
}
// This is not required with Masterminds/semver
/*
// normalizeVersion strips leading zeroes from each part, to allow comparison of ubuntu-like versions.
func normalizeVersion(version string) string {
// if it doesn't match a version string, return unchanged
if ok := regexp.MustCompile(`^(\d+)(\.\d+)?(\.\d+)?$`).MatchString(version); !ok {
// definitely not an ubuntu-like version, return unchanged
return version
}
ret := []rune{}
var cur rune
trim := true
for _, next := range version + "." {
if trim && cur == '0' && next != '.' {
cur = next
continue
}
if cur != 0 {
ret = append(ret, cur)
}
trim = (cur == '.' || cur == 0)
cur = next
}
return string(ret)
}
*/
// VersionCheck returns true if the version of the OS matches the given constraint
func (os ExprOS) VersionCheck(constraint string) (bool, error) {
v, err := semver.NewVersion(os.RawVersion)
if err != nil {
return false, err
}
c, err := semver.NewConstraint(constraint)
if err != nil {
return false, err
}
return c.Check(v), nil
}
// VersionAtLeast returns true if the version of the OS is at least the given version.
func (os ExprOS) VersionAtLeast(constraint string) (bool, error) {
return os.VersionCheck(">=" + constraint)
}
// VersionIsLower returns true if the version of the OS is lower than the given version.
func (os ExprOS) VersionIsLower(version string) (bool, error) {
result, err := os.VersionAtLeast(version)
if err != nil {
return false, err
}
return !result, nil
}
// ExprEnvironment is used to expose functions and values to the rule engine.
// It can cache the results of service detection commands, like systemctl etc.
type ExprEnvironment struct {
OS ExprOS
_serviceState *ExprServiceState
_state *ExprState
}
// NewExprEnvironment creates an environment object for the rule engine.
func NewExprEnvironment(opts DetectOptions, os ExprOS) ExprEnvironment {
return ExprEnvironment{
_state: &ExprState{
detectOptions: opts,
unitsSearched: make(map[string]bool),
installedUnits: make(map[string]bool),
processesSearched: make(map[string]bool),
runningProcesses: make(map[string]bool),
},
_serviceState: &ExprServiceState{},
OS: os,
}
}
// PathExists returns true if the given path exists.
func (e ExprEnvironment) PathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// UnitFound returns true if the unit is listed in the systemctl output.
// Whether a disabled or failed unit is considered found or not, depends on the
// systemctl parameters used.
func (e ExprEnvironment) UnitFound(unitName string) (bool, error) {
// fill initial caches
if len(e._state.unitsSearched) == 0 {
if !e._state.detectOptions.SnubSystemd {
units, err := systemdUnitList()
if err != nil {
return false, err
}
for _, name := range units {
e._state.installedUnits[name] = true
}
}
for _, name := range e._state.detectOptions.ForcedUnits {
e._state.installedUnits[name] = true
}
}
e._state.unitsSearched[unitName] = true
if e._state.installedUnits[unitName] {
e._serviceState.detectedUnits = append(e._serviceState.detectedUnits, unitName)
return true, nil
}
return false, nil
}
// ProcessRunning returns true if there is a running process with the given name.
func (e ExprEnvironment) ProcessRunning(processName string) (bool, error) {
if len(e._state.processesSearched) == 0 {
procs, err := process.Processes()
if err != nil {
return false, fmt.Errorf("while looking up running processes: %w", err)
}
for _, p := range procs {
name, err := p.Name()
if err != nil {
return false, fmt.Errorf("while looking up running processes: %w", err)
}
e._state.runningProcesses[name] = true
}
for _, name := range e._state.detectOptions.ForcedProcesses {
e._state.runningProcesses[name] = true
}
}
e._state.processesSearched[processName] = true
return e._state.runningProcesses[processName], nil
}
// applyRules checks if the 'when' expressions are true and returns a Service struct,
// augmented with default values and anything that might be useful later on
//
// All expressions are evaluated (no short-circuit) because we want to know if there are errors.
func applyRules(svc Service, env ExprEnvironment) (Service, bool, error) {
newsvc := svc
svcok := true
env._serviceState = &ExprServiceState{}
for _, rule := range svc.When {
out, err := expr.Eval(rule, env)
log.Tracef(" Rule '%s' -> %t, %v", rule, out, err)
if err != nil {
return Service{}, false, fmt.Errorf("rule '%s': %w", rule, err)
}
outbool, ok := out.(bool)
if !ok {
return Service{}, false, fmt.Errorf("rule '%s': type must be a boolean", rule)
}
svcok = svcok && outbool
}
// if newsvc.Acquis == nil || (newsvc.Acquis.LogFiles == nil && newsvc.Acquis.JournalCTLFilter == nil) {
// for _, unitName := range env._serviceState.detectedUnits {
// if newsvc.Acquis == nil {
// newsvc.Acquis = &AcquisItem{}
// }
// // if there is reference to more than one unit in the rules, we use the first one
// newsvc.Acquis.JournalCTLFilter = []string{fmt.Sprintf(`_SYSTEMD_UNIT=%s`, unitName)}
// break //nolint // we want to exit after one iteration
// }
// }
return newsvc, svcok, nil
}
// filterWithRules decorates a DetectConfig map by filtering according to the when: clauses,
// and applying default values or whatever useful to the Service items.
func filterWithRules(dc DetectConfig, env ExprEnvironment) (map[string]Service, error) {
ret := make(map[string]Service)
for name := range dc.Detect {
//
// an empty list of when: clauses defaults to true, if we want
// to change this behavior, the place is here.
// if len(svc.When) == 0 {
// log.Warningf("empty 'when' clause: %+v", svc)
// }
//
log.Trace("Evaluating rules for: ", name)
svc, ok, err := applyRules(dc.Detect[name], env)
if err != nil {
return nil, fmt.Errorf("while looking for service %s: %w", name, err)
}
if !ok {
log.Tracef(" Skipping %s", name)
continue
}
log.Tracef(" Detected %s", name)
ret[name] = svc
}
return ret, nil
}
// return units that have been forced but not searched yet.
func (e ExprEnvironment) unsearchedUnits() []string {
ret := []string{}
for _, unit := range e._state.detectOptions.ForcedUnits {
if !e._state.unitsSearched[unit] {
ret = append(ret, unit)
}
}
return ret
}
// return processes that have been forced but not searched yet.
func (e ExprEnvironment) unsearchedProcesses() []string {
ret := []string{}
for _, proc := range e._state.detectOptions.ForcedProcesses {
if !e._state.processesSearched[proc] {
ret = append(ret, proc)
}
}
return ret
}
// checkConsumedForcedItems checks if all the "forced" options (units or processes) have been evaluated during the service detection.
func checkConsumedForcedItems(e ExprEnvironment) error {
unconsumed := e.unsearchedUnits()
unitMsg := ""
if len(unconsumed) > 0 {
unitMsg = fmt.Sprintf("unit(s) forced but not supported: %v", unconsumed)
}
unconsumed = e.unsearchedProcesses()
procsMsg := ""
if len(unconsumed) > 0 {
procsMsg = fmt.Sprintf("process(es) forced but not supported: %v", unconsumed)
}
join := ""
if unitMsg != "" && procsMsg != "" {
join = "; "
}
if unitMsg != "" || procsMsg != "" {
return fmt.Errorf("%s%s%s", unitMsg, join, procsMsg)
}
return nil
}
// DetectOptions contains parameters for the Detect function.
type DetectOptions struct {
// slice of unit names that we want to force-detect
ForcedUnits []string
// slice of process names that we want to force-detect
ForcedProcesses []string
ForcedOS ExprOS
SkipServices []string
SnubSystemd bool
}
// Detect performs the service detection from a given configuration.
// It outputs a setup file that can be used as input to "cscli setup install-hub"
// or "cscli setup datasources".
func Detect(serviceDetectionFile string, opts DetectOptions) (Setup, error) {
ret := Setup{}
// explicitly initialize to avoid json mashaling an empty slice as "null"
ret.Setup = make([]ServiceSetup, 0)
log.Tracef("Reading detection rules: %s", serviceDetectionFile)
sc, err := readDetectConfig(serviceDetectionFile)
if err != nil {
return ret, err
}
// // generate acquis.yaml snippet for this service
// for key := range sc.Detect {
// svc := sc.Detect[key]
// if svc.Acquis != nil {
// svc.AcquisYAML, err = yaml.Marshal(svc.Acquis)
// if err != nil {
// return ret, err
// }
// sc.Detect[key] = svc
// }
// }
var osfull *osinfo.OSInfo
os := opts.ForcedOS
if os == (ExprOS{}) {
osfull, err = osinfo.GetOSInfo()
if err != nil {
return ret, fmt.Errorf("detecting OS: %w", err)
}
log.Tracef("Detected OS - %+v", *osfull)
os = ExprOS{
Family: osfull.Family,
ID: osfull.ID,
RawVersion: osfull.Version,
}
} else {
log.Tracef("Forced OS - %+v", os)
}
if len(opts.ForcedUnits) > 0 {
log.Tracef("Forced units - %v", opts.ForcedUnits)
}
if len(opts.ForcedProcesses) > 0 {
log.Tracef("Forced processes - %v", opts.ForcedProcesses)
}
env := NewExprEnvironment(opts, os)
detected, err := filterWithRules(sc, env)
if err != nil {
return ret, err
}
if err = checkConsumedForcedItems(env); err != nil {
return ret, err
}
// remove services the user asked to ignore
for _, name := range opts.SkipServices {
delete(detected, name)
}
// sort the keys (service names) to have them in a predictable
// order in the final output
keys := make([]string, 0)
for k := range detected {
keys = append(keys, k)
}
sort.Strings(keys)
for _, name := range keys {
svc := detected[name]
// if svc.DataSource != nil {
// if svc.DataSource.Labels["type"] == "" {
// return Setup{}, fmt.Errorf("missing type label for service %s", name)
// }
// err = yaml.Unmarshal(svc.AcquisYAML, svc.DataSource)
// if err != nil {
// return Setup{}, fmt.Errorf("while unmarshaling datasource for service %s: %w", name, err)
// }
// }
ret.Setup = append(ret.Setup, ServiceSetup{
DetectedService: name,
Install: svc.Install,
DataSource: svc.DataSource,
})
}
return ret, nil
}
// ListSupported parses the configuration file and outputs a list of the supported services.
func ListSupported(serviceDetectionFile string) ([]string, error) {
dc, err := readDetectConfig(serviceDetectionFile)
if err != nil {
return nil, err
}
keys := make([]string, 0)
for k := range dc.Detect {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}