From 1397d73ff07ebb9b1f7392fa31f7d8c5dba8d225 Mon Sep 17 00:00:00 2001 From: Hemant Kumar Date: Sat, 25 Jul 2026 22:41:20 +0530 Subject: [PATCH] feat: support exempt_files to skip source code overwrite detection Honors the exempt_files value sent by the harden-runner action so that configured paths are excluded from source code overwrite detection. Paths are matched by full path or trailing path segment, since the action sends repo-relative paths while file events carry absolute paths. Related to step-security/harden-runner#330. --- agent.go | 2 +- config.go | 16 ++++++++++++++++ config_test.go | 24 ++++++++++++++++++++++++ eventhandler.go | 16 +++++++++++++++- eventhandler_test.go | 26 ++++++++++++++++++++++++++ procmon.go | 1 + procmon_linux.go | 2 +- 7 files changed, 84 insertions(+), 3 deletions(-) diff --git a/agent.go b/agent.go index acee4eb..4888c64 100644 --- a/agent.go +++ b/agent.go @@ -137,7 +137,7 @@ func Run(ctx context.Context, configFilePath string, hostDNSServer DNSServer, // start proc mon if cmd == nil { procMon := &ProcessMonitor{CorrelationId: config.CorrelationId, Repo: config.Repo, - ApiClient: apiclient, WorkingDirectory: config.WorkingDirectory, DisableFileMonitoring: config.DisableFileMonitoring, DNSProxy: &dnsProxy} + ApiClient: apiclient, WorkingDirectory: config.WorkingDirectory, DisableFileMonitoring: config.DisableFileMonitoring, ExemptFiles: config.ExemptFiles, DNSProxy: &dnsProxy} go procMon.MonitorProcesses(errc) WriteLog("started process monitor") } diff --git a/config.go b/config.go index 61a7793..cbd05c3 100644 --- a/config.go +++ b/config.go @@ -24,6 +24,7 @@ type config struct { DisableSudo bool DisableSudoAndContainers bool DisableFileMonitoring bool + ExemptFiles []string Private bool } @@ -46,6 +47,7 @@ type configFile struct { DisableSudo bool `json:"disable_sudo"` DisableSudoAndContainers bool `json:"disable_sudo_and_containers"` DisableFileMonitoring bool `json:"disable_file_monitoring"` + ExemptFiles string `json:"exempt_files"` Private bool `json:"private"` } @@ -77,11 +79,25 @@ func (c *config) init(configFilePath string) error { c.DisableSudo = configFile.DisableSudo c.DisableSudoAndContainers = configFile.DisableSudoAndContainers c.DisableFileMonitoring = configFile.DisableFileMonitoring + c.ExemptFiles = parseExemptFiles(configFile.ExemptFiles) c.Private = configFile.Private c.OneTimeKey = configFile.OneTimeKey return nil } +// parseExemptFiles splits the exempt_files config value into individual paths. +// It splits on newlines only so that paths containing spaces are preserved. +func parseExemptFiles(exemptFiles string) []string { + var files []string + for _, line := range strings.Split(exemptFiles, "\n") { + trimmed := strings.TrimSpace(line) + if len(trimmed) > 0 { + files = append(files, trimmed) + } + } + return files +} + func parseEndpoints(allowedEndpoints string) map[string][]Endpoint { endpoints := make(map[string][]Endpoint) endpointsArray := strings.Split(allowedEndpoints, " ") diff --git a/config_test.go b/config_test.go index 6243ec0..bf4f0ef 100644 --- a/config_test.go +++ b/config_test.go @@ -36,6 +36,30 @@ func Test_config_init(t *testing.T) { } } +func Test_parseExemptFiles(t *testing.T) { + type args struct { + exemptFiles string + } + tests := []struct { + name string + args args + want []string + }{ + {name: "empty", args: args{exemptFiles: ""}, want: nil}, + {name: "one path per line", args: args{exemptFiles: "dist/index.js\npackage-lock.json"}, want: []string{"dist/index.js", "package-lock.json"}}, + {name: "trims whitespace and blank lines", args: args{exemptFiles: " dist/index.js \n\n go.sum "}, want: []string{"dist/index.js", "go.sum"}}, + {name: "preserves spaces within a path", args: args{exemptFiles: "my dir/file.txt\nother.txt"}, want: []string{"my dir/file.txt", "other.txt"}}, + {name: "handles CRLF", args: args{exemptFiles: "dist/index.js\r\npackage-lock.json\r\n"}, want: []string{"dist/index.js", "package-lock.json"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseExemptFiles(tt.args.exemptFiles); !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseExemptFiles() = %v, want %v", got, tt.want) + } + }) + } +} + func Test_parseEndpoints(t *testing.T) { type args struct { allowedEndpoints string diff --git a/eventhandler.go b/eventhandler.go index 1051338..29c3dd1 100644 --- a/eventhandler.go +++ b/eventhandler.go @@ -28,6 +28,7 @@ type EventHandler struct { ProcessMap map[string]*Process SourceCodeMap map[string][]*Event FileOverwriteCounterMap map[string]int // to count file overwrites by an exe + ExemptFiles []string // files exempted from overwrite detection netMutex sync.RWMutex fileMutex sync.RWMutex procMutex sync.RWMutex @@ -60,7 +61,7 @@ func (eventHandler *EventHandler) handleFileEvent(event *Event) { // Uncomment to log file writes (only uncomment in INT env) // WriteLog(fmt.Sprintf("file write %s, syscall %s", event.FileName, event.Syscall)) - if isSourceCodeFile(event.FileName) { + if isSourceCodeFile(event.FileName) && !eventHandler.isExemptFile(event.FileName) { eventHandler.fileMutex.Lock() defer eventHandler.fileMutex.Unlock() @@ -103,6 +104,19 @@ func isSourceCodeFile(fileName string) bool { return false } +// isExemptFile returns true when the file matches one of the configured exempt +// paths. Exempt entries are usually repo-relative while events carry absolute +// paths, so we match on the full path or a trailing path segment. +func (eventHandler *EventHandler) isExemptFile(fileName string) bool { + for _, exempt := range eventHandler.ExemptFiles { + if fileName == exempt || strings.HasSuffix(fileName, "/"+exempt) { + return true + } + } + + return false +} + func (eventHandler *EventHandler) handleProcessEvent(event *Event) { eventHandler.procMutex.Lock() _, found := eventHandler.ProcessMap[event.Pid] diff --git a/eventhandler_test.go b/eventhandler_test.go index d79fe95..5fcbbbf 100644 --- a/eventhandler_test.go +++ b/eventhandler_test.go @@ -69,6 +69,32 @@ func TestEventHandler_HandleEvent(t *testing.T) { } } +func TestEventHandler_isExemptFile(t *testing.T) { + eventHandler := &EventHandler{ + ExemptFiles: []string{"dist/index.js", "package-lock.json", "my dir/notes.txt"}, + } + + tests := []struct { + name string + fileName string + want bool + }{ + {name: "repo-relative path suffix match", fileName: "/home/runner/work/repo/repo/dist/index.js", want: true}, + {name: "single filename suffix match", fileName: "/home/runner/work/repo/repo/package-lock.json", want: true}, + {name: "exact match", fileName: "dist/index.js", want: true}, + {name: "path with space preserved", fileName: "/home/runner/work/repo/my dir/notes.txt", want: true}, + {name: "non-exempt file", fileName: "/home/runner/work/repo/repo/src/main.go", want: false}, + {name: "partial name is not exempted", fileName: "/home/runner/work/repo/repo/not-package-lock.json", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := eventHandler.isExemptFile(tt.fileName); got != tt.want { + t.Errorf("isExemptFile(%q) = %v, want %v", tt.fileName, got, tt.want) + } + }) + } +} + func TestGetContainerIdByPid(t *testing.T) { type args struct { cgroupPath string diff --git a/procmon.go b/procmon.go index e84c5a2..a75970c 100644 --- a/procmon.go +++ b/procmon.go @@ -20,6 +20,7 @@ type ProcessMonitor struct { DNSProxy *DNSProxy WorkingDirectory string DisableFileMonitoring bool + ExemptFiles []string Events map[int]*Event mutex sync.RWMutex } diff --git a/procmon_linux.go b/procmon_linux.go index 9f87550..1f4fb4d 100644 --- a/procmon_linux.go +++ b/procmon_linux.go @@ -153,7 +153,7 @@ func (p *ProcessMonitor) MonitorProcesses(errc chan error) { func (p *ProcessMonitor) receive(r *libaudit.AuditClient) error { p.Events = make(map[int]*Event) - eventHandler := EventHandler{CorrelationId: p.CorrelationId, Repo: p.Repo, ApiClient: p.ApiClient, DNSProxy: p.DNSProxy} + eventHandler := EventHandler{CorrelationId: p.CorrelationId, Repo: p.Repo, ApiClient: p.ApiClient, DNSProxy: p.DNSProxy, ExemptFiles: p.ExemptFiles} eventHandler.ProcessConnectionMap = make(map[string]bool) eventHandler.ProcessFileMap = make(map[string]bool) eventHandler.SourceCodeMap = make(map[string][]*Event)