From 6fb962a94180abca0e9d82fc8fe533786f8e3695 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Wed, 11 Jan 2023 15:01:02 +0100 Subject: [PATCH] Allow parsers to capture data for future enrichment (#1969) * Allow parsers to capture data in a cache, that can be later accessed via expr helpers (fake multi-line support) --- cmd/crowdsec-cli/metrics.go | 16 ++- cmd/crowdsec-cli/metrics_table.go | 35 ++++++ cmd/crowdsec/metrics.go | 11 +- go.mod | 1 + go.sum | 2 + pkg/cache/cache.go | 119 ++++++++++++++++++ pkg/cache/cache_test.go | 30 +++++ pkg/exprhelpers/exprlib.go | 3 + pkg/parser/node.go | 107 +++++++++++++++- pkg/parser/parsing_test.go | 7 +- pkg/parser/test_data/GeoLite2-ASN.mmdb | Bin 3168 -> 12665 bytes .../base-grok-stash/base-grok-stash.yaml | 31 +++++ pkg/parser/tests/base-grok-stash/parsers.yaml | 2 + pkg/parser/tests/base-grok-stash/test.yaml | 63 ++++++++++ pkg/parser/tests/geoip-enrich/base-grok.yaml | 1 + pkg/parser/tests/geoip-enrich/test.yaml | 7 +- pkg/types/grok_pattern.go | 15 ++- 17 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 pkg/cache/cache.go create mode 100644 pkg/cache/cache_test.go create mode 100644 pkg/parser/tests/base-grok-stash/base-grok-stash.yaml create mode 100644 pkg/parser/tests/base-grok-stash/parsers.yaml create mode 100644 pkg/parser/tests/base-grok-stash/test.yaml diff --git a/cmd/crowdsec-cli/metrics.go b/cmd/crowdsec-cli/metrics.go index e77eb40a8..aae1fe1fb 100644 --- a/cmd/crowdsec-cli/metrics.go +++ b/cmd/crowdsec-cli/metrics.go @@ -57,6 +57,10 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string) error lapi_bouncer_stats := map[string]map[string]map[string]int{} decisions_stats := map[string]map[string]map[string]int{} alerts_stats := map[string]int{} + stash_stats := map[string]struct { + Type string + Count int + }{} for idx, fam := range result { if !strings.HasPrefix(fam.Name, "cs_") { @@ -93,6 +97,8 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string) error origin := metric.Labels["origin"] action := metric.Labels["action"] + mtype := metric.Labels["type"] + fval, err := strconv.ParseFloat(value, 32) if err != nil { log.Errorf("Unexpected int value %s : %s", value, err) @@ -208,6 +214,11 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string) error alerts_stats[scenario] = make(map[string]int) }*/ alerts_stats[reason] += ival + case "cs_cache_size": + stash_stats[name] = struct { + Type string + Count int + }{Type: mtype, Count: ival} default: continue } @@ -225,8 +236,9 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string) error lapiDecisionStatsTable(out, lapi_decisions_stats) decisionStatsTable(out, decisions_stats) alertStatsTable(out, alerts_stats) + stashStatsTable(out, stash_stats) } else if formatType == "json" { - for _, val := range []interface{}{acquis_stats, parsers_stats, buckets_stats, lapi_stats, lapi_bouncer_stats, lapi_machine_stats, lapi_decisions_stats, decisions_stats, alerts_stats} { + for _, val := range []interface{}{acquis_stats, parsers_stats, buckets_stats, lapi_stats, lapi_bouncer_stats, lapi_machine_stats, lapi_decisions_stats, decisions_stats, alerts_stats, stash_stats} { x, err := json.MarshalIndent(val, "", " ") if err != nil { return fmt.Errorf("failed to unmarshal metrics : %v", err) @@ -236,7 +248,7 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string) error return nil } else if formatType == "raw" { - for _, val := range []interface{}{acquis_stats, parsers_stats, buckets_stats, lapi_stats, lapi_bouncer_stats, lapi_machine_stats, lapi_decisions_stats, decisions_stats, alerts_stats} { + for _, val := range []interface{}{acquis_stats, parsers_stats, buckets_stats, lapi_stats, lapi_bouncer_stats, lapi_machine_stats, lapi_decisions_stats, decisions_stats, alerts_stats, stash_stats} { x, err := yaml.Marshal(val) if err != nil { return fmt.Errorf("failed to unmarshal metrics : %v", err) diff --git a/cmd/crowdsec-cli/metrics_table.go b/cmd/crowdsec-cli/metrics_table.go index f55d89cd2..6cdd0a077 100644 --- a/cmd/crowdsec-cli/metrics_table.go +++ b/cmd/crowdsec-cli/metrics_table.go @@ -129,6 +129,41 @@ func parserStatsTable(out io.Writer, stats map[string]map[string]int) { } } +func stashStatsTable(out io.Writer, stats map[string]struct { + Type string + Count int +}) { + + t := newTable(out) + t.SetRowLines(false) + t.SetHeaders("Name", "Type", "Items") + t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft) + + // unfortunately, we can't reuse metricsToTable as the structure is too different :/ + sortedKeys := []string{} + for k := range stats { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + numRows := 0 + for _, alabel := range sortedKeys { + astats := stats[alabel] + + row := []string{ + alabel, + astats.Type, + fmt.Sprintf("%d", astats.Count), + } + t.AddRow(row...) + numRows++ + } + if numRows > 0 { + renderTableTitle(out, "\nParser Stash Metrics:") + t.Render() + } +} + func lapiStatsTable(out io.Writer, stats map[string]map[string]int) { t := newTable(out) t.SetRowLines(false) diff --git a/cmd/crowdsec/metrics.go b/cmd/crowdsec/metrics.go index e01915827..6b9c1e53a 100644 --- a/cmd/crowdsec/metrics.go +++ b/cmd/crowdsec/metrics.go @@ -5,6 +5,7 @@ import ( "time" v1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/controllers/v1" + "github.com/crowdsecurity/crowdsec/pkg/cache" "github.com/crowdsecurity/crowdsec/pkg/csconfig" "github.com/crowdsecurity/crowdsec/pkg/cwversion" "github.com/crowdsecurity/crowdsec/pkg/database" @@ -100,6 +101,10 @@ var globalPourHistogram = prometheus.NewHistogramVec( func computeDynamicMetrics(next http.Handler, dbClient *database.Client) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //update cache metrics (stash) + cache.UpdateCacheMetrics() + + //decision metrics are only relevant for LAPI if dbClient == nil { next.ServeHTTP(w, r) return @@ -160,7 +165,8 @@ func registerPrometheus(config *csconfig.PrometheusCfg) { globalCsInfo, globalParsingHistogram, globalPourHistogram, leaky.BucketsUnderflow, leaky.BucketsCanceled, leaky.BucketsInstantiation, leaky.BucketsOverflow, v1.LapiRouteHits, - leaky.BucketsCurrentCount) + leaky.BucketsCurrentCount, + cache.CacheMetrics) } else { log.Infof("Loading prometheus collectors") prometheus.MustRegister(globalParserHits, globalParserHitsOk, globalParserHitsKo, @@ -168,7 +174,8 @@ func registerPrometheus(config *csconfig.PrometheusCfg) { globalCsInfo, globalParsingHistogram, globalPourHistogram, v1.LapiRouteHits, v1.LapiMachineHits, v1.LapiBouncerHits, v1.LapiNilDecisions, v1.LapiNonNilDecisions, v1.LapiResponseTime, leaky.BucketsPour, leaky.BucketsUnderflow, leaky.BucketsCanceled, leaky.BucketsInstantiation, leaky.BucketsOverflow, leaky.BucketsCurrentCount, - globalActiveDecisions, globalAlerts) + globalActiveDecisions, globalAlerts, + cache.CacheMetrics) } } diff --git a/go.mod b/go.mod index fa083aaee..11b01f35e 100644 --- a/go.mod +++ b/go.mod @@ -91,6 +91,7 @@ require ( github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bluele/gcache v0.0.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/containerd/containerd v1.6.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect diff --git a/go.sum b/go.sum index 0dcb50ef9..6b3f98c72 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/blackfireio/osinfo v1.0.3 h1:Yk2t2GTPjBcESv6nDSWZKO87bGMQgO+Hi9OoXPpxX8c= github.com/blackfireio/osinfo v1.0.3/go.mod h1:Pd987poVNmd5Wsx6PRPw4+w7kLlf9iJxoRKPtPAjOrA= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= +github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/c-robinson/iplib v1.0.3 h1:NG0UF0GoEsrC1/vyfX1Lx2Ss7CySWl3KqqXh3q4DdPU= diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 000000000..f575ea9aa --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,119 @@ +package cache + +import ( + "time" + + "github.com/bluele/gcache" + "github.com/crowdsecurity/crowdsec/pkg/types" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + log "github.com/sirupsen/logrus" +) + +var Caches []gcache.Cache +var CacheNames []string +var CacheConfig []CacheCfg + +/*prometheus*/ +var CacheMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "cs_cache_size", + Help: "Entries per cache.", + }, + []string{"name", "type"}, +) + +// UpdateCacheMetrics is called directly by the prom handler +func UpdateCacheMetrics() { + CacheMetrics.Reset() + for i, name := range CacheNames { + CacheMetrics.With(prometheus.Labels{"name": name, "type": CacheConfig[i].Strategy}).Set(float64(Caches[i].Len(false))) + } +} + +type CacheCfg struct { + Name string + Size int + TTL time.Duration + Strategy string + LogLevel *log.Level + Logger *log.Entry +} + +func CacheInit(cfg CacheCfg) error { + + for _, name := range CacheNames { + if name == cfg.Name { + log.Infof("Cache %s already exists", cfg.Name) + } + } + //get a default logger + if cfg.LogLevel == nil { + cfg.LogLevel = new(log.Level) + *cfg.LogLevel = log.InfoLevel + } + var clog = logrus.New() + if err := types.ConfigureLogger(clog); err != nil { + log.Fatalf("While creating cache logger : %s", err) + } + clog.SetLevel(*cfg.LogLevel) + cfg.Logger = clog.WithFields(log.Fields{ + "cache": cfg.Name, + }) + + tmpCache := gcache.New(cfg.Size) + switch cfg.Strategy { + case "LRU": + tmpCache = tmpCache.LRU() + case "LFU": + tmpCache = tmpCache.LFU() + case "ARC": + tmpCache = tmpCache.ARC() + default: + cfg.Strategy = "LRU" + tmpCache = tmpCache.LRU() + + } + + CTICache := tmpCache.Build() + Caches = append(Caches, CTICache) + CacheNames = append(CacheNames, cfg.Name) + CacheConfig = append(CacheConfig, cfg) + + return nil +} + +func SetKey(cacheName string, key string, value string, expiration *time.Duration) error { + + for i, name := range CacheNames { + if name == cacheName { + if expiration == nil { + expiration = &CacheConfig[i].TTL + } + CacheConfig[i].Logger.Debugf("Setting key %s to %s with expiration %v", key, value, *expiration) + if err := Caches[i].SetWithExpire(key, value, *expiration); err != nil { + CacheConfig[i].Logger.Warningf("While setting key %s in cache %s: %s", key, cacheName, err) + } + } + } + return nil +} + +func GetKey(cacheName string, key string) (string, error) { + for i, name := range CacheNames { + if name == cacheName { + if value, err := Caches[i].Get(key); err != nil { + //do not warn or log if key not found + if err == gcache.KeyNotFoundError { + return "", nil + } + CacheConfig[i].Logger.Warningf("While getting key %s in cache %s: %s", key, cacheName, err) + return "", err + } else { + return value.(string), nil + } + } + } + log.Warningf("Cache %s not found", cacheName) + return "", nil +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 000000000..a4e0bd012 --- /dev/null +++ b/pkg/cache/cache_test.go @@ -0,0 +1,30 @@ +package cache + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCreateSetGet(t *testing.T) { + err := CacheInit(CacheCfg{Name: "test", Size: 100, TTL: 1 * time.Second}) + assert.Empty(t, err) + //set & get + err = SetKey("test", "testkey0", "testvalue1", nil) + assert.Empty(t, err) + + ret, err := GetKey("test", "testkey0") + assert.Equal(t, "testvalue1", ret) + assert.Empty(t, err) + //re-set + err = SetKey("test", "testkey0", "testvalue2", nil) + assert.Empty(t, err) + assert.Equal(t, "testvalue1", ret) + assert.Empty(t, err) + //expire + time.Sleep(1500 * time.Millisecond) + ret, err = GetKey("test", "testkey0") + assert.Equal(t, "", ret) + assert.Empty(t, err) +} diff --git a/pkg/exprhelpers/exprlib.go b/pkg/exprhelpers/exprlib.go index 0686b8cc5..c911a0871 100644 --- a/pkg/exprhelpers/exprlib.go +++ b/pkg/exprhelpers/exprlib.go @@ -14,6 +14,7 @@ import ( "github.com/c-robinson/iplib" + "github.com/crowdsecurity/crowdsec/pkg/cache" "github.com/crowdsecurity/crowdsec/pkg/database" "github.com/davecgh/go-spew/spew" log "github.com/sirupsen/logrus" @@ -69,6 +70,8 @@ func GetExprEnv(ctx map[string]interface{}) map[string]interface{} { "GetDecisionsSinceCount": GetDecisionsSinceCount, "Sprintf": fmt.Sprintf, "ParseUnix": ParseUnix, + "GetFromStash": cache.GetKey, + "SetInStash": cache.SetKey, } for k, v := range ctx { ExprLib[k] = v diff --git a/pkg/parser/node.go b/pkg/parser/node.go index b6cad0023..91456e37b 100644 --- a/pkg/parser/node.go +++ b/pkg/parser/node.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "strings" + "time" "github.com/antonmedv/expr" "github.com/crowdsecurity/grokky" @@ -11,6 +12,7 @@ import ( yaml "gopkg.in/yaml.v2" "github.com/antonmedv/expr/vm" + "github.com/crowdsecurity/crowdsec/pkg/cache" "github.com/crowdsecurity/crowdsec/pkg/exprhelpers" "github.com/crowdsecurity/crowdsec/pkg/types" "github.com/davecgh/go-spew/spew" @@ -57,6 +59,8 @@ type Node struct { Grok types.GrokPattern `yaml:"grok,omitempty"` //Statics can be present in any type of node and is executed last Statics []types.ExtraField `yaml:"statics,omitempty"` + //Stash allows to capture data from the log line and store it in an accessible cache + Stash []types.DataCapture `yaml:"stash,omitempty"` //Whitelists Whitelist Whitelist `yaml:"whitelist,omitempty"` Data []*types.DataSource `yaml:"data,omitempty"` @@ -103,6 +107,25 @@ func (n *Node) validate(pctx *UnixParserCtx, ectx EnricherCtx) error { } } } + + for idx, stash := range n.Stash { + if stash.Name == "" { + return fmt.Errorf("stash %d : name must be set", idx) + } + if stash.Value == "" { + return fmt.Errorf("stash %s : value expression must be set", stash.Name) + } + if stash.Key == "" { + return fmt.Errorf("stash %s : key expression must be set", stash.Name) + } + if stash.TTL == "" { + return fmt.Errorf("stash %s : ttl must be set", stash.Name) + } + //should be configurable + if stash.MaxMapSize == 0 { + stash.MaxMapSize = 100 + } + } return nil } @@ -285,6 +308,50 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx, expressionEnv map[stri clog.Tracef("! No grok pattern : %p", n.Grok.RunTimeRegexp) } + //Process the stash (data collection) if : a grok was present and succeeded, or if there is no grok + if NodeHasOKGrok || n.Grok.RunTimeRegexp == nil { + for idx, stash := range n.Stash { + var value string + var key string + if stash.ValueExpression == nil { + clog.Warningf("Stash %d has no value expression, skipping", idx) + continue + } + if stash.KeyExpression == nil { + clog.Warningf("Stash %d has no key expression, skipping", idx) + continue + } + //collect the data + output, err := expr.Run(stash.ValueExpression, cachedExprEnv) + if err != nil { + clog.Warningf("Error while running stash val expression : %v", err) + } + //can we expect anything else than a string ? + switch output := output.(type) { + case string: + value = output + default: + clog.Warningf("unexpected type %t (%v) while running '%s'", output, output, stash.Value) + continue + } + + //collect the key + output, err = expr.Run(stash.KeyExpression, cachedExprEnv) + if err != nil { + clog.Warningf("Error while running stash key expression : %v", err) + } + //can we expect anything else than a string ? + switch output := output.(type) { + case string: + key = output + default: + clog.Warningf("unexpected type %t (%v) while running '%s'", output, output, stash.Key) + continue + } + cache.SetKey(stash.Name, key, value, &stash.TTLVal) + } + } + //Iterate on leafs if len(n.LeavesNodes) > 0 { for _, leaf := range n.LeavesNodes { @@ -434,10 +501,10 @@ func (n *Node) compile(pctx *UnixParserCtx, ectx EnricherCtx) error { n.Logger.Tracef("+ Regexp Compilation '%s'", n.Grok.RegexpName) n.Grok.RunTimeRegexp, err = pctx.Grok.Get(n.Grok.RegexpName) if err != nil { - return fmt.Errorf("Unable to find grok '%s' : %v", n.Grok.RegexpName, err) + return fmt.Errorf("unable to find grok '%s' : %v", n.Grok.RegexpName, err) } if n.Grok.RunTimeRegexp == nil { - return fmt.Errorf("Empty grok '%s'", n.Grok.RegexpName) + return fmt.Errorf("empty grok '%s'", n.Grok.RegexpName) } n.Logger.Tracef("%s regexp: %s", n.Grok.RegexpName, n.Grok.RunTimeRegexp.Regexp.String()) valid = true @@ -447,11 +514,11 @@ func (n *Node) compile(pctx *UnixParserCtx, ectx EnricherCtx) error { } n.Grok.RunTimeRegexp, err = pctx.Grok.Compile(n.Grok.RegexpValue) if err != nil { - return fmt.Errorf("Failed to compile grok '%s': %v\n", n.Grok.RegexpValue, err) + return fmt.Errorf("failed to compile grok '%s': %v", n.Grok.RegexpValue, err) } if n.Grok.RunTimeRegexp == nil { // We shouldn't be here because compilation succeeded, so regexp shouldn't be nil - return fmt.Errorf("Grok compilation failure: %s", n.Grok.RegexpValue) + return fmt.Errorf("grok compilation failure: %s", n.Grok.RegexpValue) } n.Logger.Tracef("%s regexp : %s", n.Grok.RegexpValue, n.Grok.RunTimeRegexp.Regexp.String()) valid = true @@ -480,6 +547,38 @@ func (n *Node) compile(pctx *UnixParserCtx, ectx EnricherCtx) error { } valid = true } + + /* load data capture (stash) */ + for i, stash := range n.Stash { + n.Stash[i].ValueExpression, err = expr.Compile(stash.Value, + expr.Env(exprhelpers.GetExprEnv(map[string]interface{}{"evt": &types.Event{}}))) + if err != nil { + return errors.Wrap(err, "while compiling stash value expression") + } + + n.Stash[i].KeyExpression, err = expr.Compile(stash.Key, + expr.Env(exprhelpers.GetExprEnv(map[string]interface{}{"evt": &types.Event{}}))) + if err != nil { + return errors.Wrap(err, "while compiling stash key expression") + } + + n.Stash[i].TTLVal, err = time.ParseDuration(stash.TTL) + if err != nil { + return errors.Wrap(err, "while parsing stash ttl") + } + + logLvl := n.Logger.Logger.GetLevel() + //init the cache, does it make sense to create it here just to be sure everything is fine ? + if err := cache.CacheInit(cache.CacheCfg{ + Size: n.Stash[i].MaxMapSize, + TTL: n.Stash[i].TTLVal, + Name: n.Stash[i].Name, + LogLevel: &logLvl, + }); err != nil { + return errors.Wrap(err, "while initializing cache") + } + } + /* compile leafs if present */ if len(n.LeavesNodes) > 0 { for idx := range n.LeavesNodes { diff --git a/pkg/parser/parsing_test.go b/pkg/parser/parsing_test.go index 4344d0cb7..cde09ee5b 100644 --- a/pkg/parser/parsing_test.go +++ b/pkg/parser/parsing_test.go @@ -138,7 +138,7 @@ func testOneParser(pctx *UnixParserCtx, ectx EnricherCtx, dir string, b *testing return nil } -//prepTests is going to do the initialisation of parser : it's going to load enrichment plugins and load the patterns. This is done here so that we don't redo it for each test +// prepTests is going to do the initialisation of parser : it's going to load enrichment plugins and load the patterns. This is done here so that we don't redo it for each test func prepTests() (*UnixParserCtx, EnricherCtx, error) { var ( err error @@ -252,6 +252,7 @@ func matchEvent(expected types.Event, out types.Event, debug bool) ([]string, bo if debug { retInfo = append(retInfo, fmt.Sprintf("mismatch %s[%s] %s != %s", outLabels[mapIdx], expKey, expVal, outVal)) } + valid = false goto checkFinished } } else { //missing entry @@ -266,11 +267,11 @@ func matchEvent(expected types.Event, out types.Event, debug bool) ([]string, bo checkFinished: if valid { if debug { - retInfo = append(retInfo, fmt.Sprintf("OK ! %s", strings.Join(retInfo, "/"))) + retInfo = append(retInfo, fmt.Sprintf("OK ! \n\t%s", strings.Join(retInfo, "\n\t"))) } } else { if debug { - retInfo = append(retInfo, fmt.Sprintf("KO ! %s", strings.Join(retInfo, "/"))) + retInfo = append(retInfo, fmt.Sprintf("KO ! \n\t%s", strings.Join(retInfo, "\n\t"))) } } return retInfo, valid diff --git a/pkg/parser/test_data/GeoLite2-ASN.mmdb b/pkg/parser/test_data/GeoLite2-ASN.mmdb index bbf6bf2609008fbdbf299620081c68149a5f5be8..bc31924dc15183886762c9fba8e630aa53165bc2 100644 GIT binary patch literal 12665 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 literal 3168 zcmZYA2Y3@@7{KxON*S{EDurXOQlRW1dv#K1filB2xt6p^E?h2!7G&ezihF=taqq2E zapT^bI#Ch#7X9x2HL2RCPk(vW_kDMlJ}07yXd6XR6gx#4DW;305;cY<&=i^}juBD6 z?m%;B0WF~ww1zg&7TQ63=l~s|6Lf|y&=tBtcSwUCkPbbe7wFI%`aoak2mN6H41_^2 z7>2-5$beyx3BzFojD##01*2gMjD>M99wxvheB8&BEN|&Br1YpSOkk<36#K6SO&{s1+0Ws;Dgm*sQpeBDOGF| z@skXIsgjiV(vYFDV}Q;{nbFN$16@@lw7C7%|#R`GzybtJEc8(=To2sgpaa0}cDx54dj z2i&QazF*`nqPyW9xEJp8XmGFHj}Io2@(_38VUb4|cogDug>q*oLbm$FzpfB`;{xARr0`EU< zFwqbg3K=jAGGRE3fRT^|qhK_Qfw3?S#=``d2$Ntk@cz@L5KVE#|---@o=eW*QV}Ysr|ob+2uwgSYtRrD>A>>48Gh}S=_T{xwjJ~v5mTQX zGX0MFoZ&U>J#Zx7Sf3w^1Ty_r_|WpwV8pPiePxy%HXL8rsIY8bm1)P+iT0%Yw;GHj ztGjbc*&1BPRi8Zp!7R7cIcxu%sDbj++_(~Amn17^%`2czzl z9-LuDRu28As;jDAY{ndYR{gX-sLZG|b=8nvn5DaGX6WjCy1Cv6M?M9Skvxf#<^)h;;^D`5KkRy^X=YCCgn({I@UUo2Q-?&