From 2a0b2f2b99d53112313164acf99733cb6681332d Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 12:55:45 +0600 Subject: [PATCH 1/8] Refactor auditlog and improve collectors Refactor audit log handling and improve hardware/software collectors across platforms. - Replace ad-hoc periodic audit ticker with a logging integration started/stopped by the CLI; remove the program-level audit goroutine and startup of AuditLogService. - AuditLogService: introduce HTTP helpers (newRequest, doRequest), use context-aware SendBatchLogsWithContext, tighten types to map[string]any, improved logging, and smaller error messages; remove legacy generators/tests and deprecated batch helpers. - RAM parsing: massively refactor dmidecode parsing with helper functions (fieldValue, applyDmidecodeField), better fallback logic (readTotalMemKB, ramTypeFromEDAC, lshw helpers, manufacturerFromDMI) and cleaner lshw parsing. - Software collectors: set FirstSeenAt when available (mac: LastModified from system profiler, rpm: INSTALLTIME, windows: InstallDate, browser extensions: file mod time). - Minor fixes: make systemd unit write exempt from gosec, normalize mac GPU CurrentStatus to "unknown". These changes centralize HTTP request handling for audit logs, improve metadata accuracy for RAM and installed software, and simplify lifecycle management for the logging/audit flow. --- cmd/sentinelgo/cli/commands.go | 47 +-- cmd/sentinelgo/service/program.go | 37 --- cmd/sentinelgo/service/svc_linux.go | 2 +- internal/osinfo/gpu/gpu_darwin.go | 2 +- internal/osinfo/ram/ram_linux.go | 306 ++++++++++--------- internal/service/auditlog/auditlog.go | 290 ++++-------------- internal/service/auditlog/auditlog_test.go | 182 ----------- internal/service/software/collect_darwin.go | 7 +- internal/service/software/collect_linux.go | 11 +- internal/service/software/collect_windows.go | 10 +- internal/service/software/extensions.go | 6 +- 11 files changed, 275 insertions(+), 625 deletions(-) diff --git a/cmd/sentinelgo/cli/commands.go b/cmd/sentinelgo/cli/commands.go index 2a5b947..ba7c647 100644 --- a/cmd/sentinelgo/cli/commands.go +++ b/cmd/sentinelgo/cli/commands.go @@ -14,50 +14,35 @@ import ( "sentinelgo/internal/logging" "sentinelgo/internal/osinfo" agentsvc "sentinelgo/internal/service/agent" - auditlogsvc "sentinelgo/internal/service/auditlog" authsvc "sentinelgo/internal/service/auth" ) func HandleAuditLogsStandalone(cfg *config.Config) { fmt.Println("Starting audit logs service in standalone mode...") - auditService := auditlogsvc.NewAuditLogService(cfg) + li, err := logging.NewLoggingIntegration(cfg) + if err != nil { + fmt.Printf("Failed to create logging integration: %v\n", err) + os.Exit(1) + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - appCfg := &auditlogsvc.AppConfig{ - ConfigPath: cfg.Path, - EventType: "system_check", - OSType: runtime.GOOS, - UptimeSeconds: 0, - } - - batchData := auditService.CreateBatchData(appCfg) - if err := auditService.SendBatchLogsWithContext(ctx, batchData); err != nil { - log.Printf("Failed to send audit logs: %v", err) - } else { - log.Println("Audit logs sent successfully") - } - } - } - }() + if err := li.Start(ctx); err != nil { + fmt.Printf("Failed to start logging service: %v\n", err) + os.Exit(1) + } fmt.Println("Audit logs service is running. Press Ctrl+C to stop.") + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan - fmt.Println("\nAudit logs service stopped") + + fmt.Println("\nStopping audit logs service...") + if err := li.Stop(); err != nil { + fmt.Printf("Warning: failed to stop logging service: %v\n", err) + } } func HandleAuditLogsStatus(cfg *config.Config) { diff --git a/cmd/sentinelgo/service/program.go b/cmd/sentinelgo/service/program.go index cb99b9f..8c41569 100644 --- a/cmd/sentinelgo/service/program.go +++ b/cmd/sentinelgo/service/program.go @@ -4,14 +4,11 @@ import ( "context" "fmt" "log" - "runtime" - "time" "sentinelgo/internal" "sentinelgo/internal/config" "sentinelgo/internal/lockfile" "sentinelgo/internal/sanitize" - auditlogsvc "sentinelgo/internal/service/auditlog" ) // Program implements the service start/stop lifecycle. It is constructed in @@ -20,7 +17,6 @@ type Program struct { Cfg *config.Config lockFile *lockfile.LockFile mainIntegration *internal.MainIntegration - auditService *auditlogsvc.AuditLogService ctx context.Context cancel context.CancelFunc } @@ -78,42 +74,9 @@ func (p *Program) Start(_ AgentService) error { } }() - if p.Cfg.AuditLogsEnabled { - p.auditService = auditlogsvc.NewAuditLogService(p.Cfg) - go p.runAuditLogsService() - log.Println("Audit logs service started") - } - return nil } -func (p *Program) runAuditLogsService() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-p.ctx.Done(): - log.Println("Audit logs service stopped") - return - case <-ticker.C: - appCfg := &auditlogsvc.AppConfig{ - ConfigPath: p.Cfg.Path, - EventType: "system_check", - OSType: runtime.GOOS, - UptimeSeconds: 0, - } - - batchData := p.auditService.CreateBatchData(appCfg) - if err := p.auditService.SendBatchLogsWithContext(p.ctx, batchData); err != nil { - log.Printf("Failed to send audit logs: %v", err) - } else { - log.Println("Audit logs sent successfully") - } - } - } -} - // Stop is called by the platform service manager to shut the agent down. func (p *Program) Stop(_ AgentService) error { if err := logger.Info("Stopping SentinelGo service"); err != nil { diff --git a/cmd/sentinelgo/service/svc_linux.go b/cmd/sentinelgo/service/svc_linux.go index 26b15d8..edb647e 100644 --- a/cmd/sentinelgo/service/svc_linux.go +++ b/cmd/sentinelgo/service/svc_linux.go @@ -62,7 +62,7 @@ func (ls *linuxService) Install() error { execStart := strings.Join(parts, " ") unit := fmt.Sprintf(linuxUnitFmt, ls.cfg.Description, execStart) - if err := os.WriteFile(linuxUnitPath, []byte(unit), 0644); err != nil { + if err := os.WriteFile(linuxUnitPath, []byte(unit), 0644); err != nil { //nolint:gosec // systemd unit files must be world-readable return fmt.Errorf("write unit file: %w", err) } diff --git a/internal/osinfo/gpu/gpu_darwin.go b/internal/osinfo/gpu/gpu_darwin.go index d466b10..a354fdc 100644 --- a/internal/osinfo/gpu/gpu_darwin.go +++ b/internal/osinfo/gpu/gpu_darwin.go @@ -70,7 +70,7 @@ func getGPUs() []shared.GPU { DriverVersion: "Unknown", DriverDate: "Unknown", HardwareID: "Unknown", - CurrentStatus: "OK", + CurrentStatus: "unknown", } if v, ok := dm["_name"].(string); ok { g.Name = v diff --git a/internal/osinfo/ram/ram_linux.go b/internal/osinfo/ram/ram_linux.go index 0c428a7..ba5f14b 100644 --- a/internal/osinfo/ram/ram_linux.go +++ b/internal/osinfo/ram/ram_linux.go @@ -36,13 +36,61 @@ func isMeaningless(s string) bool { return false } +// fieldValue splits a "Key: Value" dmidecode line and returns the trimmed value. +// ok is false when the line has no colon separator. +func fieldValue(line string) (string, bool) { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return "", false + } + return strings.TrimSpace(parts[1]), true +} + +// applyDmidecodeField sets the appropriate field on stick from a single dmidecode line. +func applyDmidecodeField(line string, stick *shared.RAMStick, sizeBytes *uint64) { + switch { + case strings.HasPrefix(line, "Size:"): + if v, ok := fieldValue(line); ok { + *sizeBytes = parseMemorySize(v) + } + case strings.HasPrefix(line, "Manufacturer:"): + if v, ok := fieldValue(line); ok { + stick.Manufacturer = normalizeRAMManufacturer(v) + } + case strings.HasPrefix(line, "Part Number:"): + if v, ok := fieldValue(line); ok && !isMeaningless(v) { + stick.Name = v + } + case strings.HasPrefix(line, "Type:"): + if v, ok := fieldValue(line); ok && !isMeaningless(v) { + stick.ArchitectureType = v + } + case strings.HasPrefix(line, "Speed:"): + if v, ok := fieldValue(line); ok { + var speed int + _, _ = fmt.Sscanf(v, "%d", &speed) + stick.ClockSpeedMHz = speed + } + case strings.HasPrefix(line, "Serial Number:"): + if v, ok := fieldValue(line); ok && !isMeaningless(v) { + stick.Serial = v + } + case strings.HasPrefix(line, "Form Factor:"): + if v, ok := fieldValue(line); ok && !isMeaningless(v) { + stick.FormFactor = v + } + case strings.HasPrefix(line, "Locator:") && !strings.HasPrefix(line, "Bank Locator:"): + if v, ok := fieldValue(line); ok && !isMeaningless(v) { + stick.Slot = v + } + } +} + // parseDmidecodeOutput parses the output of "dmidecode -t memory" into RAM sticks. // It is exported for testing. func parseDmidecodeOutput(output string) ([]shared.RAMStick, uint64) { var rams []shared.RAMStick var totalBytes uint64 - - lines := strings.Split(output, "\n") var current *shared.RAMStick var currentSizeBytes uint64 @@ -56,72 +104,13 @@ func parseDmidecodeOutput(output string) ([]shared.RAMStick, uint64) { currentSizeBytes = 0 } - for _, line := range lines { + for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Memory Device") { flush() current = &shared.RAMStick{} } else if current != nil { - switch { - case strings.HasPrefix(line, "Size:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - currentSizeBytes = parseMemorySize(strings.TrimSpace(parts[1])) - } - case strings.HasPrefix(line, "Manufacturer:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - v := normalizeRAMManufacturer(strings.TrimSpace(parts[1])) - current.Manufacturer = v - } - case strings.HasPrefix(line, "Part Number:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - v := strings.TrimSpace(parts[1]) - if !isMeaningless(v) { - current.Name = v - } - } - case strings.HasPrefix(line, "Type:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - v := strings.TrimSpace(parts[1]) - if !isMeaningless(v) { - current.ArchitectureType = v - } - } - case strings.HasPrefix(line, "Speed:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - var speed int - _, _ = fmt.Sscanf(strings.TrimSpace(parts[1]), "%d", &speed) - current.ClockSpeedMHz = speed - } - case strings.HasPrefix(line, "Serial Number:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - v := strings.TrimSpace(parts[1]) - if !isMeaningless(v) { - current.Serial = v - } - } - case strings.HasPrefix(line, "Form Factor:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - v := strings.TrimSpace(parts[1]) - if !isMeaningless(v) { - current.FormFactor = v - } - } - case strings.HasPrefix(line, "Locator:") && !strings.HasPrefix(line, "Bank Locator:"): - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - v := strings.TrimSpace(parts[1]) - if !isMeaningless(v) { - current.Slot = v - } - } - } + applyDmidecodeField(line, current, ¤tSizeBytes) } } flush() @@ -129,99 +118,134 @@ func parseDmidecodeOutput(output string) ([]shared.RAMStick, uint64) { return rams, totalBytes } -// fallbackFromProcMeminfo is used when dmidecode is unavailable or returns no sticks. -// It reads /proc/meminfo for total size and probes EDAC/lshw/DMI for type metadata. -func fallbackFromProcMeminfo() ([]shared.RAMStick, uint64) { - var totalMemKB uint64 - if meminfo, err := shared.ReadFileContent("/proc/meminfo"); err == nil { - for _, line := range strings.Split(meminfo, "\n") { - if strings.HasPrefix(line, "MemTotal:") { - parts := strings.Fields(line) - if len(parts) >= 2 { - if kb, err := strconv.ParseUint(parts[1], 10, 64); err == nil { - totalMemKB = kb - } +// readTotalMemKB returns the MemTotal value from /proc/meminfo in kilobytes, or 0 on failure. +func readTotalMemKB() uint64 { + meminfo, err := shared.ReadFileContent("/proc/meminfo") + if err != nil { + return 0 + } + for line := range strings.SplitSeq(meminfo, "\n") { + if strings.HasPrefix(line, "MemTotal:") { + parts := strings.Fields(line) + if len(parts) >= 2 { + if kb, err := strconv.ParseUint(parts[1], 10, 64); err == nil { + return kb } - break } + break } } - if totalMemKB == 0 { - return nil, 0 + return 0 +} + +// ramTypeFromEDAC probes the EDAC kernel subsystem for the memory type of the first DIMM. +func ramTypeFromEDAC() string { + if dimmType, err := shared.ReadFileContent("/sys/devices/system/edac/mc/mc0/dimm0/dimm_mem_type"); err == nil { + if t := strings.TrimSpace(dimmType); t != "" { + return t + } } + if mcType, err := shared.ReadFileContent("/sys/devices/system/edac/mc/mc0/mc_type"); err == nil { + return strings.TrimSpace(mcType) + } + return "" +} - var ramType, manufacturer string - var clockSpeed int +// lshwRAMType extracts the DDR generation string from uppercased lshw output. +func lshwRAMType(upper string) string { + switch { + case strings.Contains(upper, "LPDDR5"): + return "LPDDR5" + case strings.Contains(upper, "LPDDR4"): + return "LPDDR4" + case strings.Contains(upper, "DDR5"): + return "DDR5" + case strings.Contains(upper, "DDR4"): + return "DDR4" + case strings.Contains(upper, "DDR3"): + return "DDR3" + case strings.Contains(upper, "DDR2"): + return "DDR2" + } + return "" +} - // EDAC subsystem exposes per-DIMM memory type when available - if dimmType, err := shared.ReadFileContent("/sys/devices/system/edac/mc/mc0/dimm0/dimm_mem_type"); err == nil { - ramType = strings.TrimSpace(dimmType) +// lshwManufacturer extracts the memory vendor from lshw -C memory output. +func lshwManufacturer(lshwOut string) string { + for line := range strings.SplitSeq(lshwOut, "\n") { + if !strings.Contains(line, "vendor:") { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + continue + } + vendor := strings.TrimSpace(parts[1]) + if vendor != "" && vendor != "0x" && vendor != "0x0000" { + return vendor + } } - if ramType == "" { - if mcType, err := shared.ReadFileContent("/sys/devices/system/edac/mc/mc0/mc_type"); err == nil { - ramType = strings.TrimSpace(mcType) - } - } - - if ramType == "" || clockSpeed == 0 || manufacturer == "" { - if lshwOut, err := shared.RunCommand("lshw", "-C", "memory"); err == nil { - if ramType == "" { - upper := strings.ToUpper(lshwOut) - switch { - case strings.Contains(upper, "LPDDR5"): - ramType = "LPDDR5" - case strings.Contains(upper, "LPDDR4"): - ramType = "LPDDR4" - case strings.Contains(upper, "DDR5"): - ramType = "DDR5" - case strings.Contains(upper, "DDR4"): - ramType = "DDR4" - case strings.Contains(upper, "DDR3"): - ramType = "DDR3" - case strings.Contains(upper, "DDR2"): - ramType = "DDR2" - } - } - if manufacturer == "" { - for _, line := range strings.Split(lshwOut, "\n") { - if strings.Contains(line, "vendor:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) >= 2 { - vendor := strings.TrimSpace(parts[1]) - if vendor != "" && vendor != "0x" && vendor != "0x0000" { - manufacturer = vendor - break - } - } - } - } - } - // lshw reports memory bus speed as "clock:NNNNMHz" - if clockSpeed == 0 { - for _, line := range strings.Split(lshwOut, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "clock:") { - var speed int - if _, err := fmt.Sscanf(trimmed, "clock:%dMHz", &speed); err == nil && speed > 0 { - clockSpeed = speed - break - } - } - } - } + return "" +} + +// lshwClockSpeed extracts the memory bus speed in MHz from lshw -C memory output. +func lshwClockSpeed(lshwOut string) int { + for line := range strings.SplitSeq(lshwOut, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "clock:") { + continue + } + var speed int + if _, err := fmt.Sscanf(trimmed, "clock:%dMHz", &speed); err == nil && speed > 0 { + return speed } } + return 0 +} - // Board vendor is the best approximation available without per-DIMM data +// enrichFromLshw fills any missing RAM metadata fields using lshw -C memory. +func enrichFromLshw(ramType, manufacturer string, clockSpeed int) (string, string, int) { + lshwOut, err := shared.RunCommand("lshw", "-C", "memory") + if err != nil { + return ramType, manufacturer, clockSpeed + } + if ramType == "" { + ramType = lshwRAMType(strings.ToUpper(lshwOut)) + } if manufacturer == "" { - if boardVendor, err := shared.ReadFileContent("/sys/class/dmi/id/board_vendor"); err == nil { - manufacturer = strings.TrimSpace(boardVendor) + manufacturer = lshwManufacturer(lshwOut) + } + if clockSpeed == 0 { + clockSpeed = lshwClockSpeed(lshwOut) + } + return ramType, manufacturer, clockSpeed +} + +// manufacturerFromDMI reads the board or system vendor from DMI sysfs as a last resort. +func manufacturerFromDMI() string { + if boardVendor, err := shared.ReadFileContent("/sys/class/dmi/id/board_vendor"); err == nil { + if v := strings.TrimSpace(boardVendor); v != "" { + return v } } + if sysVendor, err := shared.ReadFileContent("/sys/class/dmi/id/sys_vendor"); err == nil { + return strings.TrimSpace(sysVendor) + } + return "" +} + +// fallbackFromProcMeminfo is used when dmidecode is unavailable or returns no sticks. +// It reads /proc/meminfo for total size and probes EDAC/lshw/DMI for type metadata. +func fallbackFromProcMeminfo() ([]shared.RAMStick, uint64) { + totalMemKB := readTotalMemKB() + if totalMemKB == 0 { + return nil, 0 + } + + ramType := ramTypeFromEDAC() + ramType, manufacturer, clockSpeed := enrichFromLshw(ramType, "", 0) if manufacturer == "" { - if sysVendor, err := shared.ReadFileContent("/sys/class/dmi/id/sys_vendor"); err == nil { - manufacturer = strings.TrimSpace(sysVendor) - } + manufacturer = manufacturerFromDMI() } if ramType == "" { diff --git a/internal/service/auditlog/auditlog.go b/internal/service/auditlog/auditlog.go index b9b1f88..1a21be5 100644 --- a/internal/service/auditlog/auditlog.go +++ b/internal/service/auditlog/auditlog.go @@ -16,19 +16,18 @@ import ( "sentinelgo/internal/models" ) +const ( + rpcInsertBatch = "/rest/v1/rpc/agent_insert_audit_logs_batch" + headerContentType = "Content-Type" + headerAuthorization = "Authorization" + headerXDeviceID = "X-Device-ID" + mimeJSON = "application/json" + bearerPrefix = "Bearer " +) + // ErrInvalidInput is returned when an audit log is missing required fields. var ErrInvalidInput = errors.New("invalid audit log input") -// AppConfig holds application configuration from command line flags -type AppConfig struct { - ConfigPath string - EventType string - OSType string - OSVerBefore string - OSVerAfter string - UptimeSeconds int64 -} - // AuditLogService handles audit log operations with batch processing type AuditLogService struct { config *config.Config @@ -65,9 +64,42 @@ func auditLogCategoryKey(category string) string { } } +// newRequest builds a POST request to the batch RPC endpoint with all required headers set. +func (s *AuditLogService) newRequest(ctx context.Context, body []byte) (*http.Request, error) { + url := s.config.SupabaseURL + rpcInsertBatch + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set(headerContentType, mimeJSON) + req.Header.Set(headerAuthorization, bearerPrefix+s.config.AccessToken) + req.Header.Set(headerXDeviceID, s.config.DeviceID) + req.Header.Set("apikey", s.config.SupabaseKey) + return req, nil +} + +// doRequest executes the request and returns an error for non-2xx responses. +func (s *AuditLogService) doRequest(req *http.Request) error { + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + // sendToSupabase sends an audit log to the agent_insert_audit_logs_batch RPC. func (s *AuditLogService) sendToSupabase(ctx context.Context, auditLog models.AuditLog) error { - entry := map[string]interface{}{ + entry := map[string]any{ "event_type": auditLog.EventType, "created_at": auditLog.Timestamp, "severity": auditLog.Severity, @@ -78,50 +110,32 @@ func (s *AuditLogService) sendToSupabase(ctx context.Context, auditLog models.Au categoryKey := auditLogCategoryKey(auditLog.LogCategory) - data, err := json.Marshal(map[string]interface{}{ - "payload": map[string]interface{}{ + data, err := json.Marshal(map[string]any{ + "payload": map[string]any{ "device_id": s.config.DeviceID, "os_type": auditLog.OSType, "agent_version": auditLog.AgentVersion, "source": auditLog.Source, - "logs": []interface{}{map[string]interface{}{categoryKey: []interface{}{entry}}}, + "logs": []any{map[string]any{categoryKey: []any{entry}}}, }, }) if err != nil { return fmt.Errorf("marshal audit log: %w", err) } - url := s.config.SupabaseURL + "/rest/v1/rpc/agent_insert_audit_logs_batch" - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) + req, err := s.newRequest(ctx, data) if err != nil { return fmt.Errorf("create request: %w", err) } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+s.config.AccessToken) - req.Header.Set("X-Device-ID", s.config.DeviceID) - req.Header.Set("apikey", s.config.SupabaseKey) - - resp, err := s.client.Do(req) - if err != nil { - return fmt.Errorf("send audit log: %w", err) + if err := s.doRequest(req); err != nil { + return fmt.Errorf("audit log upload failed: %w", err) } - defer func() { - if err := resp.Body.Close(); err != nil { - _ = err - } - }() - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("audit log upload failed with status %d: %s", resp.StatusCode, string(body)) - } - return nil } // ProcessBatchLogs processes multiple audit logs from batch data -func (s *AuditLogService) ProcessBatchLogs(ctx context.Context, batchData map[string]interface{}) error { +func (s *AuditLogService) ProcessBatchLogs(ctx context.Context, batchData map[string]any) error { agentID, ok := batchData["agent_id"].(string) if !ok { return fmt.Errorf("agent_id is required in batch data") @@ -142,7 +156,7 @@ func (s *AuditLogService) ProcessBatchLogs(ctx context.Context, batchData map[st return fmt.Errorf("source is required in batch data") } - logs, ok := batchData["logs"].([]interface{}) + logs, ok := batchData["logs"].([]any) if !ok { return fmt.Errorf("logs array is required in batch data") } @@ -151,13 +165,13 @@ func (s *AuditLogService) ProcessBatchLogs(ctx context.Context, batchData map[st for _, logEntry := range logs { logJSON, err := json.Marshal(logEntry) if err != nil { - fmt.Printf("Warning: Failed to marshal log entry: %v\n", err) + log.Printf("warning: failed to marshal log entry: %v", err) continue } var auditLog models.AuditLog if err := json.Unmarshal(logJSON, &auditLog); err != nil { - fmt.Printf("Warning: Failed to parse log entry: %v\n", err) + log.Printf("warning: failed to parse log entry: %v", err) continue } @@ -168,215 +182,39 @@ func (s *AuditLogService) ProcessBatchLogs(ctx context.Context, batchData map[st auditLog.Source = source if err := s.sendToSupabase(ctx, auditLog); err != nil { - fmt.Printf("Warning: Failed to send batch log entry: %v\n", err) + log.Printf("warning: failed to send batch log entry: %v", err) continue } processedCount++ } - fmt.Printf("Successfully processed %d log entries from batch\n", processedCount) + log.Printf("successfully processed %d log entries from batch", processedCount) return nil } -// GenerateSystemLogs generates system log entries -func (s *AuditLogService) GenerateSystemLogs(appCfg *AppConfig) []map[string]interface{} { - var systemLogs []map[string]interface{} - - systemLogs = append(systemLogs, map[string]interface{}{ - "event_type": appCfg.EventType, - "created_at": time.Now().UTC().Format(time.RFC3339), - "severity": "low", - "log_category": "SYSTEM_LOG", - "event_data": map[string]interface{}{ - "os_version_before": appCfg.OSVerBefore, - "os_version_after": appCfg.OSVerAfter, - "uptime_seconds": appCfg.UptimeSeconds, - }, - }) - - if appCfg.EventType == "upgrade" { - systemLogs = append(systemLogs, map[string]interface{}{ - "event_type": "upgrade", - "created_at": time.Now().UTC().Format(time.RFC3339), - "severity": "low", - "log_category": "SYSTEM_LOG", - "event_data": map[string]interface{}{ - "os_version_before": appCfg.OSVerBefore, - "os_version_after": appCfg.OSVerAfter, - "source": "package_manager", - "detection": "automatic", - "action_taken": "System upgrade completed", - }, - }) - } - - return systemLogs -} - -// GenerateSecurityLogs generates security log entries -func (s *AuditLogService) GenerateSecurityLogs(appCfg *AppConfig) []map[string]interface{} { - var securityLogs []map[string]interface{} - - securityLogs = append(securityLogs, map[string]interface{}{ - "event_type": "local_login_success", - "created_at": time.Now().UTC().Format(time.RFC3339), - "severity": "medium", - "log_category": "SECURITY_LOG", - "event_data": map[string]interface{}{ - "authentication_source": "local_pc", - "action_taken": "session opened", - "username": "system_user", - "account_type": "user", - "login_method": "password", - }, - }) - - return securityLogs -} - -// GenerateMDMLogs generates MDM (Mobile Device Management) log entries -func (s *AuditLogService) GenerateMDMLogs(appCfg *AppConfig) []map[string]interface{} { - var mdmLogs []map[string]interface{} - - mdmLogs = append(mdmLogs, map[string]interface{}{ - "event_type": "policy_applied", - "created_at": time.Now().UTC().Format(time.RFC3339), - "severity": "medium", - "log_category": "POLICY_LOG", - "event_data": map[string]interface{}{ - "policy_id": "POL-001", - "policy_type": "security", - "apply_status": "success", - "initiated_by": "system", - "policy_name": "Default Security Policy", - "rules_applied": 5, - "execution_time": "2s", - }, - }) - - return mdmLogs -} - -// GenerateNetworkLogs generates network log entries -func (s *AuditLogService) GenerateNetworkLogs(appCfg *AppConfig) []map[string]interface{} { - var networkLogs []map[string]interface{} - - networkLogs = append(networkLogs, map[string]interface{}{ - "event_type": "connection_established", - "created_at": time.Now().UTC().Format(time.RFC3339), - "severity": "low", - "log_category": "NETWORK_LOG", - "event_data": map[string]interface{}{ - "interface_name": "eth0", - "network_type": "ethernet", - "connection_status": "connected", - "ip_address": "192.168.1.100", - "gateway": "192.168.1.1", - "dns_servers": []string{"192.168.1.1", "8.8.8.8"}, - }, - }) - - return networkLogs -} - -// CreateBatchData creates the complete batch JSON structure -func (s *AuditLogService) CreateBatchData(appCfg *AppConfig) map[string]interface{} { - systemLogs := s.GenerateSystemLogs(appCfg) - securityLogs := s.GenerateSecurityLogs(appCfg) - mdmLogs := s.GenerateMDMLogs(appCfg) - networkLogs := s.GenerateNetworkLogs(appCfg) - - logs := []map[string]interface{}{ - {"system": systemLogs}, - {"security": securityLogs}, - {"mdm": mdmLogs}, - {"network": networkLogs}, - } - - return map[string]interface{}{ - "payload": map[string]interface{}{ - "device_id": s.config.DeviceID, - "os_type": appCfg.OSType, - "agent_version": s.config.CurrentVersion, - "source": "agent", - "logs": logs, - }, - } -} - -// SendBatchLogs sends the batch data to Supabase batch-logs endpoint -func (s *AuditLogService) SendBatchLogs(batchData map[string]interface{}) error { - data, err := json.Marshal(batchData) - if err != nil { - return fmt.Errorf("marshal batch data: %w", err) - } - - url := s.config.SupabaseURL + "/rest/v1/rpc/agent_insert_audit_logs_batch" - req, err := http.NewRequest("POST", url, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("create batch request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+s.config.AccessToken) - req.Header.Set("X-Device-ID", s.config.DeviceID) - req.Header.Set("apikey", s.config.SupabaseKey) - - log.Printf("request data from Audit Service: %s", string(data)) - - resp, err := s.client.Do(req) - if err != nil { - return fmt.Errorf("send batch logs: %w", err) - } - defer func() { - if closeErr := resp.Body.Close(); closeErr != nil { - err = closeErr - } - }() - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("batch logs upload failed with status %d: %s", resp.StatusCode, string(body)) - } - - return nil +// SendBatchLogs sends the batch data to the Supabase batch-logs endpoint. +// Prefer SendBatchLogsWithContext when a context is available. +func (s *AuditLogService) SendBatchLogs(batchData map[string]any) error { + return s.SendBatchLogsWithContext(context.Background(), batchData) } // SendBatchLogsWithContext sends a batch of audit logs using the provided context. -func (s *AuditLogService) SendBatchLogsWithContext(ctx context.Context, batchData map[string]interface{}) error { +func (s *AuditLogService) SendBatchLogsWithContext(ctx context.Context, batchData map[string]any) error { data, err := json.Marshal(batchData) if err != nil { return fmt.Errorf("marshal batch data: %w", err) } - url := s.config.SupabaseURL + "/rest/v1/rpc/agent_insert_audit_logs_batch" - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("create batch request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+s.config.AccessToken) - req.Header.Set("X-Device-ID", s.config.DeviceID) - req.Header.Set("apikey", s.config.SupabaseKey) - - resp, err := s.client.Do(req) log.Printf("request data from Audit Service: %s", string(data)) + req, err := s.newRequest(ctx, data) if err != nil { - return fmt.Errorf("send batch logs: %w", err) + return fmt.Errorf("create batch request: %w", err) } - defer func() { - if closeErr := resp.Body.Close(); closeErr != nil { - err = closeErr - } - }() - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("batch logs upload failed with status %d: %s", resp.StatusCode, string(body)) + if err := s.doRequest(req); err != nil { + return fmt.Errorf("batch logs upload failed: %w", err) } - return nil } diff --git a/internal/service/auditlog/auditlog_test.go b/internal/service/auditlog/auditlog_test.go index bd848bb..092bd0e 100644 --- a/internal/service/auditlog/auditlog_test.go +++ b/internal/service/auditlog/auditlog_test.go @@ -83,188 +83,6 @@ func TestNewAuditLogService_NilConfig(t *testing.T) { } } -// ── GenerateSystemLogs ──────────────────────────────────────────────────────── - -func TestGenerateSystemLogs_BootEvent(t *testing.T) { - svc := auditlog.NewAuditLogService(&config.Config{}) - appCfg := &auditlog.AppConfig{ - EventType: "boot", - OSVerBefore: "14.0", - OSVerAfter: "14.0", - UptimeSeconds: 120, - } - - logs := svc.GenerateSystemLogs(appCfg) - if len(logs) != 1 { - t.Fatalf("expected 1 log entry for boot, got %d", len(logs)) - } - - entry := logs[0] - if entry["event_type"] != "boot" { - t.Errorf("expected event_type=boot, got %v", entry["event_type"]) - } - if entry["severity"] != "low" { - t.Errorf("expected severity=low, got %v", entry["severity"]) - } - if entry["log_category"] != "SYSTEM_LOG" { - t.Errorf("expected log_category=SYSTEM_LOG, got %v", entry["log_category"]) - } - if _, ok := entry["created_at"]; !ok { - t.Error("expected created_at field to be present") - } - t.Log("✅ GenerateSystemLogs boot entry structure verified") -} - -func TestGenerateSystemLogs_UpgradeEvent(t *testing.T) { - svc := auditlog.NewAuditLogService(&config.Config{}) - appCfg := &auditlog.AppConfig{ - EventType: "upgrade", - OSVerBefore: "14.0", - OSVerAfter: "14.1", - } - - logs := svc.GenerateSystemLogs(appCfg) - if len(logs) != 2 { - t.Fatalf("expected 2 log entries for upgrade event, got %d", len(logs)) - } - if logs[1]["event_type"] != "upgrade" { - t.Errorf("expected second entry event_type=upgrade, got %v", logs[1]["event_type"]) - } - - eventData, ok := logs[1]["event_data"].(map[string]interface{}) - if !ok { - t.Fatal("expected event_data to be a map") - } - if eventData["os_version_before"] != "14.0" { - t.Errorf("expected os_version_before=14.0, got %v", eventData["os_version_before"]) - } - if eventData["os_version_after"] != "14.1" { - t.Errorf("expected os_version_after=14.1, got %v", eventData["os_version_after"]) - } - t.Log("✅ GenerateSystemLogs upgrade event verified") -} - -// ── GenerateSecurityLogs ────────────────────────────────────────────────────── - -func TestGenerateSecurityLogs_Structure(t *testing.T) { - svc := auditlog.NewAuditLogService(&config.Config{}) - logs := svc.GenerateSecurityLogs(&auditlog.AppConfig{}) - - if len(logs) == 0 { - t.Fatal("expected at least one security log entry") - } - entry := logs[0] - for _, field := range []string{"event_type", "created_at", "severity", "log_category", "event_data"} { - if _, ok := entry[field]; !ok { - t.Errorf("expected field %q to be present in security log", field) - } - } - if entry["log_category"] != "SECURITY_LOG" { - t.Errorf("expected log_category=SECURITY_LOG, got %v", entry["log_category"]) - } - t.Log("✅ GenerateSecurityLogs structure verified") -} - -// ── GenerateMDMLogs ─────────────────────────────────────────────────────────── - -func TestGenerateMDMLogs_Structure(t *testing.T) { - svc := auditlog.NewAuditLogService(&config.Config{}) - logs := svc.GenerateMDMLogs(&auditlog.AppConfig{}) - - if len(logs) == 0 { - t.Fatal("expected at least one MDM log entry") - } - entry := logs[0] - if entry["event_type"] != "policy_applied" { - t.Errorf("expected event_type=policy_applied, got %v", entry["event_type"]) - } - if entry["log_category"] != "POLICY_LOG" { - t.Errorf("expected log_category=POLICY_LOG, got %v", entry["log_category"]) - } - t.Log("✅ GenerateMDMLogs structure verified") -} - -// ── GenerateNetworkLogs ─────────────────────────────────────────────────────── - -func TestGenerateNetworkLogs_Structure(t *testing.T) { - svc := auditlog.NewAuditLogService(&config.Config{}) - logs := svc.GenerateNetworkLogs(&auditlog.AppConfig{}) - - if len(logs) == 0 { - t.Fatal("expected at least one network log entry") - } - entry := logs[0] - if entry["event_type"] != "connection_established" { - t.Errorf("expected event_type=connection_established, got %v", entry["event_type"]) - } - if entry["log_category"] != "NETWORK_LOG" { - t.Errorf("expected log_category=NETWORK_LOG, got %v", entry["log_category"]) - } - t.Log("✅ GenerateNetworkLogs structure verified") -} - -// ── CreateBatchData ─────────────────────────────────────────────────────────── - -func TestCreateBatchData_PayloadEnvelope(t *testing.T) { - cfg := &config.Config{DeviceID: "test-device-id"} - svc := auditlog.NewAuditLogService(cfg) - appCfg := &auditlog.AppConfig{ - EventType: "boot", - OSType: "linux", - } - - batch := svc.CreateBatchData(appCfg) - - inner, ok := batch["payload"].(map[string]interface{}) - if !ok { - t.Fatalf("CreateBatchData must return {\"payload\": {...}}, got keys: %v", keys(batch)) - } - - for _, field := range []string{"device_id", "os_type", "agent_version", "source", "logs"} { - if _, ok := inner[field]; !ok { - t.Errorf("expected field %q inside payload, not found", field) - } - } - if inner["device_id"] != cfg.DeviceID { - t.Errorf("device_id mismatch: got %v", inner["device_id"]) - } - if inner["os_type"] != "linux" { - t.Errorf("os_type mismatch: got %v", inner["os_type"]) - } - - logs, ok := inner["logs"].([]map[string]interface{}) - if !ok { - t.Fatal("expected logs to be []map[string]interface{}") - } - if len(logs) == 0 { - t.Error("expected at least one category in logs array") - } - t.Logf("✅ CreateBatchData returned %d log categories wrapped in payload envelope", len(logs)) -} - -func TestCreateBatchData_AllCategories(t *testing.T) { - cfg := &config.Config{DeviceID: "test-device-id"} - svc := auditlog.NewAuditLogService(cfg) - appCfg := &auditlog.AppConfig{EventType: "boot", OSType: "linux"} - - batch := svc.CreateBatchData(appCfg) - inner := batch["payload"].(map[string]interface{}) - logs := inner["logs"].([]map[string]interface{}) - - found := map[string]bool{} - for _, entry := range logs { - for k := range entry { - found[k] = true - } - } - for _, cat := range []string{"system", "security", "mdm", "network"} { - if !found[cat] { - t.Errorf("expected category %q in batch logs, not found", cat) - } - } - t.Log("✅ CreateBatchData contains all four log categories") -} - // ── ProcessBatchLogs validation ─────────────────────────────────────────────── func TestProcessBatchLogs_MissingAgentID(t *testing.T) { diff --git a/internal/service/software/collect_darwin.go b/internal/service/software/collect_darwin.go index a70b01b..5e9bfe2 100644 --- a/internal/service/software/collect_darwin.go +++ b/internal/service/software/collect_darwin.go @@ -64,6 +64,7 @@ type systemProfilerOutput struct { Version string `json:"version"` Path string `json:"path"` ObtainedFrom string `json:"obtained_from"` + LastModified string `json:"lastModified"` } `json:"SPApplicationsDataType"` } @@ -83,6 +84,10 @@ func parseSystemProfilerApps(output []byte, applications *[]SoftwareInfo) { appStoreApp = "true" } now := time.Now().Format(time.RFC3339) + firstSeen := now + if app.LastModified != "" { + firstSeen = app.LastModified + } *applications = append(*applications, SoftwareInfo{ Name: app.Name, DisplayName: app.Name, @@ -92,7 +97,7 @@ func parseSystemProfilerApps(output []byte, applications *[]SoftwareInfo) { Source: source, Type: source, Status: "installed", - FirstSeenAt: now, + FirstSeenAt: firstSeen, LastSeenAt: now, IsActive: true, }) diff --git a/internal/service/software/collect_linux.go b/internal/service/software/collect_linux.go index 7032ca2..565bd99 100644 --- a/internal/service/software/collect_linux.go +++ b/internal/service/software/collect_linux.go @@ -4,6 +4,7 @@ import ( "log" "os/exec" "path/filepath" + "strconv" "strings" "time" ) @@ -35,7 +36,7 @@ func (s *SoftwareService) getDebPackages() []SoftwareInfo { } func (s *SoftwareService) getRPMPackages() []SoftwareInfo { - cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME} %{VERSION} %{SIZE}") + cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME} %{VERSION} %{SIZE} %{INSTALLTIME}\n") output, err := cmd.Output() if err != nil { log.Printf("software: rpm -qa failed: %v", err) @@ -106,6 +107,12 @@ func parseRPMPackages(output []byte, packages *[]SoftwareInfo) { continue } now := time.Now().Format(time.RFC3339) + firstSeen := now + if len(parts) >= 4 { + if ts, err := strconv.ParseInt(parts[3], 10, 64); err == nil && ts > 0 { + firstSeen = time.Unix(ts, 0).UTC().Format(time.RFC3339) + } + } *packages = append(*packages, SoftwareInfo{ Name: parts[0], InstalledVersion: parts[1], @@ -113,7 +120,7 @@ func parseRPMPackages(output []byte, packages *[]SoftwareInfo) { Source: "rpm_packages", Type: "rpm_packages", Status: "installed", - FirstSeenAt: now, + FirstSeenAt: firstSeen, LastSeenAt: now, IsActive: true, }) diff --git a/internal/service/software/collect_windows.go b/internal/service/software/collect_windows.go index f3ecae0..09f4e53 100644 --- a/internal/service/software/collect_windows.go +++ b/internal/service/software/collect_windows.go @@ -24,7 +24,7 @@ func (s *SoftwareService) getWindowsSoftware() []SoftwareInfo { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - registryQuery := `$paths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*','HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty $paths | Where-Object {$_.DisplayName} | Select-Object @{N='Name';E={$_.DisplayName}},@{N='Version';E={$_.DisplayVersion}},@{N='InstallLocation';E={$_.InstallLocation}} | ConvertTo-Json` + registryQuery := `$paths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*','HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty $paths | Where-Object {$_.DisplayName} | Select-Object @{N='Name';E={$_.DisplayName}},@{N='Version';E={$_.DisplayVersion}},@{N='InstallLocation';E={$_.InstallLocation}},@{N='InstallDate';E={$_.InstallDate}} | ConvertTo-Json` cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", registryQuery) output, err := cmd.Output() if err != nil { @@ -72,6 +72,12 @@ func parsePowerShellOutput(output []byte, software *[]SoftwareInfo, source strin installLocation, _ := item["InstallLocation"].(string) now := time.Now().Format(time.RFC3339) + firstSeen := now + if installDate, _ := item["InstallDate"].(string); installDate != "" { + if t, err := time.Parse("20060102", installDate); err == nil { + firstSeen = t.UTC().Format(time.RFC3339) + } + } *software = append(*software, SoftwareInfo{ Name: name, InstalledVersion: version, @@ -79,7 +85,7 @@ func parsePowerShellOutput(output []byte, software *[]SoftwareInfo, source strin Source: source, Type: source, Status: "installed", - FirstSeenAt: now, + FirstSeenAt: firstSeen, LastSeenAt: now, IsActive: true, }) diff --git a/internal/service/software/extensions.go b/internal/service/software/extensions.go index aac50c3..4fa1fba 100644 --- a/internal/service/software/extensions.go +++ b/internal/service/software/extensions.go @@ -161,6 +161,10 @@ func readExtensionManifest(path, source, extType, fallbackID string) *SoftwareIn } now := time.Now().Format(time.RFC3339) + firstSeen := now + if fi, err := os.Stat(path); err == nil { + firstSeen = fi.ModTime().UTC().Format(time.RFC3339) + } return &SoftwareInfo{ Name: m.Name, DisplayName: m.Name, @@ -169,7 +173,7 @@ func readExtensionManifest(path, source, extType, fallbackID string) *SoftwareIn Type: extType, FilePath: path, Status: "installed", - FirstSeenAt: now, + FirstSeenAt: firstSeen, LastSeenAt: now, IsActive: true, } From fbc04b65affcd9a3a5c15e9ad1cc705b0e0e97f9 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 17:06:05 +0600 Subject: [PATCH 2/8] Fix checkpoint parsing, config safety, and startup Make audit-log checkpoint handling tolerant of different numeric types by adding CheckpointInt64/CheckpointFloat64 and updating platform collectors and parsers to use them (prevents panics after JSON round-trip). Parse syslog/mac timestamps in local time. Harden config handling: add token mutex with Get/Set accessors, atomic SaveAtomic refactor to use unique temp files and platform-specific hardening (Unix chmod / Windows DACL), reconcile persisted version with runtime, and add concurrency tests. Improve lockfile and startup semantics: use a version-independent lock name, close-before-remove, platform-specific non-destructive IsProcessRunning implementations, and make service startup synchronous with proper lock release on failure. Misc: enhanced auth token refresh logic (skewed preemptive refresh), assorted tests, and README/documentation updates. --- README.md | 205 ++++++++++----- cmd/sentinelgo/service/program.go | 28 ++- internal/auditlogs/collector/collector.go | 62 +++++ .../auditlogs/collector/collector_darwin.go | 12 +- .../auditlogs/collector/collector_linux.go | 25 +- .../auditlogs/collector/collector_parse.go | 10 +- .../auditlogs/collector/collector_test.go | 81 ++++++ .../auditlogs/collector/collector_windows.go | 9 +- internal/auth/enhanced_auth.go | 31 ++- internal/config/config.go | 93 ++++++- internal/config/save_concurrency_test.go | 57 +++++ internal/config/secure_unix.go | 17 ++ internal/config/secure_windows.go | 23 ++ internal/lockfile/lockfile.go | 38 ++- internal/lockfile/lockfile_test.go | 39 +-- internal/lockfile/lockfile_unix.go | 24 ++ internal/lockfile/lockfile_windows.go | 49 ++++ internal/logging/collection_internal_test.go | 127 ++++++++++ internal/logging/logging.go | 111 +++++---- internal/logging/uploader.go | 16 +- internal/scheduler/scheduler.go | 53 +++- internal/service/agent/agent.go | 21 +- internal/service/auditlog/auditlog.go | 6 +- internal/service/auth/auth.go | 61 +++-- internal/service/auth/jwt.go | 58 +++++ internal/service/auth/jwt_test.go | 56 +++++ internal/service/rpcutil/rpcutil.go | 41 +++ internal/service/rpcutil/rpcutil_test.go | 41 +++ internal/service/software/sync.go | 20 +- internal/service/task/executor.go | 2 +- internal/service/task/polling.go | 2 +- internal/updater/checker.go | 155 ++++++------ internal/updater/downloader.go | 12 +- internal/updater/installer.go | 209 +++++----------- internal/updater/process.go | 234 ------------------ internal/updater/version.go | 121 +++++++++ internal/updater/version_test.go | 60 +++++ internal/winsec/winsec_other.go | 7 + internal/winsec/winsec_windows.go | 53 ++++ 39 files changed, 1575 insertions(+), 694 deletions(-) create mode 100644 internal/auditlogs/collector/collector_test.go create mode 100644 internal/config/save_concurrency_test.go create mode 100644 internal/config/secure_unix.go create mode 100644 internal/config/secure_windows.go create mode 100644 internal/lockfile/lockfile_unix.go create mode 100644 internal/lockfile/lockfile_windows.go create mode 100644 internal/logging/collection_internal_test.go create mode 100644 internal/service/auth/jwt.go create mode 100644 internal/service/auth/jwt_test.go create mode 100644 internal/service/rpcutil/rpcutil.go create mode 100644 internal/service/rpcutil/rpcutil_test.go delete mode 100644 internal/updater/process.go create mode 100644 internal/updater/version.go create mode 100644 internal/updater/version_test.go create mode 100644 internal/winsec/winsec_other.go create mode 100644 internal/winsec/winsec_windows.go diff --git a/README.md b/README.md index 0ed285d..3227d1c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,110 @@ +
+ # SentinelGo -A cross-platform Go system agent that reports device health, streams audit logs, runs remote commands, and self-updates. Runs as a Windows Service, systemd unit, or launchd job. +**One lightweight agent. Total endpoint visibility. Continuous compliance.** -## What it does +A single, dependency-free binary that turns every Windows, macOS, and Linux device into a continuously-monitored, audit-ready endpoint — hardware inventory, security posture, encryption status, and tamper-evident audit logs, streamed to your backend in real time. -- **System metrics heartbeat** — CPU, memory, disk, network, battery, disk encryption, local user accounts, OSQuery version, hostname/architecture/uptime, sent periodically to a Supabase backend. -- **Software inventory** — installed apps and versions, reconciled against the local SQLite store and synced via the `agent-software` Edge Function. -- **Audit log streaming** — Windows Event Log / Linux journald / macOS unified log events, batched and uploaded with at-least-once durability (SQLite checkpoint + exponential-backoff retry). -- **Task execution** — pulls pending commands from a Supabase task queue, downloads the script payload from a private `command-scripts` Storage bucket, runs it, reports the result. -- **Self-update** — daily check of GitHub Releases, atomic binary replace, graceful restart. -- **CLI** — install/uninstall the service, run in foreground, dump status, etc. +[![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-blue)](#runs-everywhere-your-fleet-does) +[![Build](https://img.shields.io/badge/build-CGO__free%20static%20binary-success)](#why-teams-choose-sentinelgo) +[![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8)](#build-from-source) +[![License](https://img.shields.io/badge/license-MIT-green)](#license) -## Quick start +
-1. Grab a release for your OS/arch from [GitHub Releases](https://github.com/BrainStation-23/SentinelGo/releases/latest). -2. Drop it in the platform install location (`/opt/sentinelgo/` on Linux/macOS, `C:\sentinelgo\` on Windows). -3. Drop a `config.json` next to the binary — see [Configuration](#configuration) and the full reference in [`docs/02-config-module.md`](docs/02-config-module.md). -4. `./sentinelgo -install` (admin/root) to register the service, or `./sentinelgo -run` to run in the foreground. -5. For full per-OS steps, see [`installation-doc/INSTALLATION.md`](installation-doc/INSTALLATION.md). +--- -## Build from source +## Why teams choose SentinelGo + +Most compliance and asset-management tools ship a heavy stack: a kernel module here, a Python runtime there, a different installer per OS, and an agent that drifts out of date the moment you deploy it. SentinelGo takes the opposite approach. + +- **Zero dependencies, anywhere.** Every build is a `CGO_ENABLED=0` static binary. No runtime, no shared libraries, no per-machine toolchain. Drop one file on a box and it runs — the same way on a 2019 Windows Server, an Apple Silicon MacBook, and an ARM64 Linux node. +- **Deploy once, stay current forever.** Built-in self-update checks GitHub Releases, downloads the right binary for the platform, and replaces itself with rollback protection — so your fleet never falls behind. +- **Runs as a first-class service.** Native Windows Service, systemd unit, and launchd daemon. Install with one command; it survives reboots and restarts on failure. +- **Built for compliance from the ground up.** Durable, at-least-once audit-log delivery backed by a local SQLite queue means events survive network outages and reboots instead of being silently dropped. +- **Tiny footprint.** A single background service designed for low CPU and memory — built to monitor, not to get in the way. + +--- + +## What it captures + +SentinelGo gives you a live, structured picture of every endpoint — far beyond "is it online." + +### 🖥️ Complete hardware & system inventory +CPU (model, cores, clock, usage), memory, per-disk capacity and health, GPUs, RAM modules (per-slot), displays, audio devices, printers, and connected peripherals (with vendor/product IDs). Plus OS name and version, architecture, locale, timezone, uptime, and last boot — refreshed on every heartbeat. + +### 🔐 Security & compliance posture +- **Disk encryption status** across all three platforms — BitLocker (Windows), FileVault (macOS), and LUKS (Linux), including hardware-vs-software encryption type. +- **Antivirus state** — installed products, whether they're enabled, and whether definitions are current. +- **Firewall** status and per-profile configuration. +- **OS hardening** — Secure Boot, VBS / HVCI memory integrity, Credential Guard (Windows), System Integrity Protection (macOS), SELinux / AppArmor / kernel lockdown (Linux). +- **Listening ports** mapped to the owning process. +- **Firmware** — BIOS/UEFI vendor and version, TPM presence and version. + +### 🌐 Network visibility +Per-adapter details: MAC, type, link speed, connection status, IPv4/IPv6 addressing (with DHCP and subnet info), default gateway, DNS servers, and Wi-Fi SSID + signal strength. + +### 📦 Software & extension inventory +Installed applications and versions across every major source — Windows programs and Microsoft Store, Debian/RPM/Snap/Flatpak, Homebrew and casks, and the macOS App Store — with first-seen / last-seen change tracking. Includes browser-extension inventory for Chrome, Firefox, Edge, and Brave. + +### 📝 Tamper-evident audit log streaming +Continuous, normalized audit events from each platform's native source — Windows Event Log (Security, System, Defender, PowerShell, Task Scheduler, Firewall, RDP, Group Policy, and more), Linux auth/syslog and journald, and the macOS unified log. Events are categorized, severity-tagged, checkpointed, and uploaded in batches with exponential-backoff retry, so nothing is lost across restarts or outages. + +### 👥 Local account inventory +Local user accounts with group membership — without collecting sensitive credential material. + +--- + +## Runs everywhere your fleet does + +| Platform | Architectures | Service model | +|---|---|---| +| **Windows** | amd64 | Windows Service | +| **macOS** | Apple Silicon (arm64) & Intel (amd64) | launchd daemon | +| **Linux** | amd64, arm64 | systemd unit | + +Every target is cross-compiled from a single host into a static binary — no per-platform build farm required. + +--- + +## How it works -```bash -make build # dev build -> bin/sentinelgo[.exe] -make test # go test ./... -make verify-cross # type-check every GOOS/GOARCH with CGO_ENABLED=0 -make check-no-cgo # fail if any `import "C"` is reintroduced -make pre-release # quality gate + build -make release VERSION=vX.Y.Z ``` + ┌─────────────┐ authenticate (JWT) ┌──────────────────┐ + │ SentinelGo │ ─────────────────────────▶ │ │ + │ agent │ ◀───── tasks / config ──── │ Your Supabase │ + │ │ │ backend │ + │ • metrics │ ───── heartbeat ─────────▶ │ │ + │ • software │ ───── inventory ─────────▶ │ • dashboards │ + │ • auditlog │ ───── log batches ───────▶ │ • alerting │ + └─────────────┘ └──────────────────┘ + │ + └──── daily ──▶ GitHub Releases ──▶ self-update + restart +``` + +1. **Authenticate** — the agent logs in to a Supabase edge function and receives a short-lived JWT, auto-refreshed in the background. +2. **Report** — it collects system metrics and sends a heartbeat on a configurable interval (default 5 minutes), plus periodic full hardware/software inventory. +3. **Stream** — audit logs are collected, normalized, queued durably, and uploaded with at-least-once delivery. +4. **Stay current** — a daily release check downloads, verifies, and applies updates, then restarts cleanly. + +--- + +## Quick start -All builds are `CGO_ENABLED=0` static binaries, cross-compiled from a single host. Go 1.25+ required. +1. Download the release for your OS/arch from [GitHub Releases](https://github.com/BrainStation-23/SentinelGo/releases/latest). +2. Place it in the install location (`/opt/sentinelgo/` on Linux/macOS, `C:\sentinelgo\` on Windows). +3. Add a `config.json` next to the binary (see [Configuration](#configuration)). +4. Register the service: + + ```bash + ./sentinelgo -install # admin/root — installs and starts the service + ./sentinelgo -run # or run in the foreground for debugging + ``` + +Full per-OS steps: [`installation-doc/INSTALLATION.md`](installation-doc/INSTALLATION.md). + +--- ## Configuration @@ -39,64 +113,83 @@ The agent reads a single JSON file. Default locations: - Linux / macOS: `/opt/sentinelgo/.sentinelgo/config.json` - Windows: `C:\sentinelgo\.sentinelgo\config.json` -Override the path with `-config `. Selected fields: +Override the path with `-config `. Common fields: ```json { "supabase_url": "https://.supabase.co", - "access_token": "", + "supabase_key": "", + "agent_secret": "", + "auto_update": true, + "auto_update_interval": "24h", "update_interval": "5m", - "github_owner": "BrainStation-23", - "github_repo": "SentinelGo", - "current_version": "v2.1.5", - "auto_update": false, + "audit_logs_enabled": true, "software_sync_enabled": true, - "audit_log_enabled": true, - "task_execution_enabled": true + "log_flush_interval": "5m" } ``` -The full field reference lives in [`docs/02-config-module.md`](docs/02-config-module.md). The agent performs a one-time login at startup to obtain `access_token`; the JWT is auto-refreshed and persisted to the OS keychain — see [`docs/06-service-module.md`](docs/06-service-module.md). +Every field can also be set via environment variable. The agent never embeds credentials in the binary — it authenticates at runtime and rotates its JWT automatically. Full reference: [`docs/02-config-module.md`](docs/02-config-module.md). -## CLI +--- + +## Command-line interface ```bash -sentinelgo -install # install as system service (admin/root required) -sentinelgo -uninstall # remove the service -sentinelgo -run # run in foreground (foreground mode for debugging) -sentinelgo -status # service status (installed/running/version) -sentinelgo -version # print version -sentinelgo -config PATH # use a custom config file +sentinelgo -install # install as a system service (admin/root) +sentinelgo -uninstall # remove the service +sentinelgo -run # run in the foreground +sentinelgo -status # show installed/running processes and versions +sentinelgo -version # print version +sentinelgo -config PATH # use a custom config file + +sentinelgo -collect-logs # force an immediate audit-log collection +sentinelgo -upload-logs # flush pending audit logs +sentinelgo -software-list # show installed software inventory +sentinelgo -agent-info-update # refresh hardware/system inventory ``` -The full flag reference is in [`docs/agent-commands-guide.md`](docs/agent-commands-guide.md). +Full flag reference: [`docs/agent-commands-guide.md`](docs/agent-commands-guide.md). -## Architecture +--- -The high-level architecture, runtime flow, and package layout live in [`docs/08-project-overview.md`](docs/08-project-overview.md). Read that first if you want to understand how the pieces fit together. +## Build from source + +```bash +make build # dev build -> bin/sentinelgo[.exe] +make test # go test ./... +make verify-cross # type-check every GOOS/GOARCH with CGO_ENABLED=0 +make check-no-cgo # fail if any `import "C"` is reintroduced +make pre-release # quality gate + build +make release VERSION=vX.Y.Z +``` + +All builds are `CGO_ENABLED=0` static binaries cross-compiled from a single host. Go 1.25+ required. + +--- ## Documentation | Document | Covers | |---|---| -| [`docs/08-project-overview.md`](docs/08-project-overview.md) | Canonical architecture, package layout, runtime flow | -| [`docs/01-main-module.md`](docs/01-main-module.md) | `cmd/sentinelgo` CLI, flag parsing, service entry point | -| [`docs/02-config-module.md`](docs/02-config-module.md) | Configuration schema, env-var overrides, validation | -| [`docs/04-lockfile-module.md`](docs/04-lockfile-module.md) | Single-instance lock keyed on agent UUID + version | +| [`docs/08-project-overview.md`](docs/08-project-overview.md) | Architecture, package layout, runtime flow | +| [`docs/01-main-module.md`](docs/01-main-module.md) | CLI, flag parsing, service entry point | +| [`docs/02-config-module.md`](docs/02-config-module.md) | Configuration schema and validation | | [`docs/05-osinfo-module.md`](docs/05-osinfo-module.md) | Cross-platform hardware metrics | -| [`docs/06-service-module.md`](docs/06-service-module.md) | Service lifecycle, auth, kardianos interface | -| [`docs/07-updater-module.md`](docs/07-updater-module.md) | GitHub Releases check, atomic replace, restart | -| [`docs/audit-logs-architecture.md`](docs/audit-logs-architecture.md) | Audit log pipeline (collect → parse → queue → upload) | -| [`docs/agent-commands-guide.md`](docs/agent-commands-guide.md) | Full CLI command and flag reference | -| [`docs/agent-script-download.md`](docs/agent-script-download.md) | Task script download protocol | +| [`docs/06-service-module.md`](docs/06-service-module.md) | Service lifecycle and auth | +| [`docs/07-updater-module.md`](docs/07-updater-module.md) | Self-update flow | +| [`docs/audit-logs-architecture.md`](docs/audit-logs-architecture.md) | Audit-log pipeline | | [`installation-doc/INSTALLATION.md`](installation-doc/INSTALLATION.md) | Per-OS install steps | -## Security and privacy +--- + +## Security & privacy + +- All backend communication is over HTTPS with a per-agent JWT, obtained at runtime and rotated. +- Local-account collection captures usernames and group membership only — never credential material. +- Backend access is gated by Supabase Row Level Security. -- All data is transmitted over HTTPS with a per-agent JWT. -- Local user account collection excludes system accounts and only captures usernames. -- Use Supabase Row Level Security (RLS) to gate access on the backend. -- The agent never embeds credentials in the binary; the JWT is obtained at runtime and rotated. +--- ## License diff --git a/cmd/sentinelgo/service/program.go b/cmd/sentinelgo/service/program.go index 8c41569..1acb9c0 100644 --- a/cmd/sentinelgo/service/program.go +++ b/cmd/sentinelgo/service/program.go @@ -49,13 +49,17 @@ func (p *Program) Start(_ AgentService) error { version = "unknown" } sanitizedVersion := sanitize.ForLog(version) - lf := lockfile.NewLockFile(fmt.Sprintf("sentinelgo-%s", sanitizedVersion)) + + // Use a single, version-INDEPENDENT lock name. A version-suffixed name let + // two different versions run simultaneously (e.g. during/after an update), + // producing double heartbeats and double audit uploads. + lf := lockfile.NewLockFile("sentinelgo") locked, err := lf.CheckExistingLock() if err != nil { log.Printf("Warning: Failed to check existing lock: %v", err) } else if locked { - log.Printf("Another instance of SentinelGo v%s is already running", sanitizedVersion) + log.Printf("Another instance of SentinelGo is already running (this is v%s)", sanitizedVersion) return fmt.Errorf("another instance is already running") } @@ -68,11 +72,23 @@ func (p *Program) Start(_ AgentService) error { p.ctx, p.cancel = context.WithCancel(context.Background()) p.mainIntegration = internal.NewMainIntegration(p.Cfg) - go func() { - if err := p.mainIntegration.Start(p.ctx); err != nil { - log.Printf("Main integration stopped with error: %v", err) + // Run startup synchronously so an initialization failure (config validation, + // logging service init, scheduler start) propagates to the service manager, + // which then marks the service failed and applies its restart policy — + // instead of the previous behaviour where the service reported "running" + // while the agent was actually dead. Start kicks off the long-running loops + // in their own goroutines and returns promptly. + if err := p.mainIntegration.Start(p.ctx); err != nil { + log.Printf("FATAL: main integration failed to start: %v", err) + // Release the lock we just acquired so a restart isn't blocked by a + // stale lock held by this failed start. + if relErr := p.lockFile.Release(); relErr != nil { + log.Printf("Warning: failed to release lock after failed start: %v", relErr) } - }() + p.lockFile = nil + p.cancel() + return fmt.Errorf("main integration failed to start: %w", err) + } return nil } diff --git a/internal/auditlogs/collector/collector.go b/internal/auditlogs/collector/collector.go index 14ea2ad..4299c68 100644 --- a/internal/auditlogs/collector/collector.go +++ b/internal/auditlogs/collector/collector.go @@ -2,6 +2,8 @@ package collector import ( "context" + "encoding/json" + "strconv" "time" ) @@ -19,8 +21,68 @@ type RawLogEntry struct { // CheckpointData holds per-source checkpoint values. // Keys are source names, values are source-specific position markers // (Event Record ID, journal cursor, file offset, timestamp, etc.). +// +// A checkpoint map round-trips through two different paths between collection +// cycles: in-memory (where numeric values keep their original Go type, e.g. +// int64) and JSON persistence (where every number decodes back as float64). +// Readers MUST therefore use the tolerant CheckpointInt64/CheckpointFloat64 +// accessors rather than a bare type assertion — a bare `v.(float64)` panics +// when the value is still an in-memory int64, which previously crashed the +// agent on the cycle after any events were collected. type CheckpointData map[string]interface{} +// CheckpointInt64 returns the checkpoint value for key as an int64, accepting +// any numeric representation the value may carry (int64, float64, int, json.Number, +// or a numeric string). ok is false when the key is absent or not numeric. +func CheckpointInt64(cp CheckpointData, key string) (val int64, ok bool) { + v, present := cp[key] + if !present { + return 0, false + } + switch n := v.(type) { + case int64: + return n, true + case float64: + return int64(n), true + case int: + return int64(n), true + case json.Number: + i, err := n.Int64() + return i, err == nil + case string: + i, err := strconv.ParseInt(n, 10, 64) + return i, err == nil + default: + return 0, false + } +} + +// CheckpointFloat64 returns the checkpoint value for key as a float64, accepting +// any numeric representation (float64, int64, int, json.Number, or a numeric +// string). ok is false when the key is absent or not numeric. +func CheckpointFloat64(cp CheckpointData, key string) (val float64, ok bool) { + v, present := cp[key] + if !present { + return 0, false + } + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case int: + return float64(n), true + case json.Number: + f, err := n.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(n, 64) + return f, err == nil + default: + return 0, false + } +} + // Collector defines the interface for OS-specific log collection. // Each platform implements this behind build tags. type Collector interface { diff --git a/internal/auditlogs/collector/collector_darwin.go b/internal/auditlogs/collector/collector_darwin.go index 00f7c17..f6f8d61 100644 --- a/internal/auditlogs/collector/collector_darwin.go +++ b/internal/auditlogs/collector/collector_darwin.go @@ -125,7 +125,7 @@ func (c *darwinCollector) Subscribe(_ context.Context, _ chan<- RawLogEntry) err // the saved timestamp checkpoint when present, otherwise from a one-hour lookback. func (c *darwinCollector) collectOSLog(ctx context.Context, checkpoint CheckpointData) ([]RawLogEntry, CheckpointData, error) { start := time.Now().Add(-1 * time.Hour) - if ts, ok := checkpoint["oslog_timestamp"].(float64); ok && ts > 0 { + if ts, ok := CheckpointFloat64(checkpoint, "oslog_timestamp"); ok && ts > 0 { // Resume just after the last entry we saw to avoid re-reading it. start = time.Unix(int64(ts), 0).Add(1 * time.Second) } @@ -195,8 +195,12 @@ func (c *darwinCollector) collectFile(_ context.Context, path, source string, ch cpKey := source + "_offset" var offset int64 - if saved, ok := checkpoint[cpKey].(float64); ok { - offset = int64(saved) + // Tolerant read: the offset is stored as an int64 in memory and as a + // float64 after JSON persistence. A bare .(float64) silently failed on the + // in-memory path, resetting the offset to 0 and re-reading the same lines + // every cycle. + if saved, ok := CheckpointInt64(checkpoint, cpKey); ok { + offset = saved } // Check for file truncation/rotation @@ -253,7 +257,7 @@ func (c *darwinCollector) collectCrashReports(_ context.Context, checkpoint Chec } var lastProcessed float64 - if ts, ok := checkpoint["crash_reports_timestamp"].(float64); ok { + if ts, ok := CheckpointFloat64(checkpoint, "crash_reports_timestamp"); ok { lastProcessed = ts } diff --git a/internal/auditlogs/collector/collector_linux.go b/internal/auditlogs/collector/collector_linux.go index 6f535f2..7b19b9b 100644 --- a/internal/auditlogs/collector/collector_linux.go +++ b/internal/auditlogs/collector/collector_linux.go @@ -210,20 +210,25 @@ func (c *linuxCollector) collectFile(ctx context.Context, path, source string, c var offset int64 - // Check for log rotation (inode change) - if savedInode, ok := checkpoint[inodeKey]; ok { - savedInodeVal, _ := savedInode.(float64) // JSON numbers decode as float64 - if uint64(savedInodeVal) != currentInode { - // Log rotated -- read from beginning + // Check for log rotation (inode change). Tolerant accessors are used because + // checkpoint values are float64 after JSON persistence but may be other + // numeric types in memory between cycles. + if savedInode, ok := CheckpointInt64(checkpoint, inodeKey); ok { + if uint64(savedInode) != currentInode { + // Log rotated (new inode) -- read from beginning offset = 0 - } else if savedOffset, ok := checkpoint[cpKey]; ok { - offsetFloat, ok := savedOffset.(float64) - if ok { - offset = int64(offsetFloat) - } + } else if savedOffset, ok := CheckpointInt64(checkpoint, cpKey); ok { + offset = savedOffset } } + // Check for in-place truncation (e.g. logrotate copytruncate): same inode + // but the file shrank below our saved offset. Reset to the start so we don't + // wait for the file to grow back past a stale offset (mirrors the darwin path). + if offset > info.Size() { + offset = 0 + } + // Don't read past end of file if offset >= info.Size() { return nil, nil, nil diff --git a/internal/auditlogs/collector/collector_parse.go b/internal/auditlogs/collector/collector_parse.go index 860e8af..379a688 100644 --- a/internal/auditlogs/collector/collector_parse.go +++ b/internal/auditlogs/collector/collector_parse.go @@ -118,7 +118,11 @@ func parseSyslogTimestamp(line string) time.Time { if end > len(line) { end = len(line) } - if t, err := time.Parse(layout, line[:end]); err == nil { + // Parse in the local zone: syslog "Jan 2 15:04:05" timestamps carry + // no zone, and time.Parse would assume UTC — making every entry look + // hours off (and, combined with downstream time filters, risk being + // mis-ordered). ParseInLocation respects an explicit zone when present. + if t, err := time.ParseInLocation(layout, line[:end], time.Local); err == nil { // Syslog doesn't include year -- use current year if t.Year() == 0 { t = t.AddDate(time.Now().Year(), 0, 0) @@ -233,7 +237,9 @@ func parseMacFileTimestamp(line string) time.Time { if end > len(line) { continue } - if t, err := time.Parse(layout, line[:end]); err == nil { + // Local zone: macOS system.log timestamps carry no zone (see the syslog + // note above). ParseInLocation respects an explicit zone when present. + if t, err := time.ParseInLocation(layout, line[:end], time.Local); err == nil { if t.Year() == 0 { t = t.AddDate(time.Now().Year(), 0, 0) if t.After(time.Now()) { diff --git a/internal/auditlogs/collector/collector_test.go b/internal/auditlogs/collector/collector_test.go new file mode 100644 index 0000000..7a5439f --- /dev/null +++ b/internal/auditlogs/collector/collector_test.go @@ -0,0 +1,81 @@ +package collector + +import ( + "encoding/json" + "testing" +) + +func TestCheckpointInt64(t *testing.T) { + cases := []struct { + name string + val interface{} + want int64 + wantOK bool + present bool + }{ + {name: "int64 (in-memory)", val: int64(42), want: 42, wantOK: true, present: true}, + {name: "float64 (after JSON)", val: float64(42), want: 42, wantOK: true, present: true}, + {name: "int", val: 42, want: 42, wantOK: true, present: true}, + {name: "json.Number", val: json.Number("42"), want: 42, wantOK: true, present: true}, + {name: "numeric string", val: "42", want: 42, wantOK: true, present: true}, + {name: "non-numeric string", val: "abc", want: 0, wantOK: false, present: true}, + {name: "wrong type", val: []int{1}, want: 0, wantOK: false, present: true}, + {name: "absent", present: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cp := CheckpointData{} + if tc.present { + cp["k"] = tc.val + } + got, ok := CheckpointInt64(cp, "k") + if ok != tc.wantOK || got != tc.want { + t.Errorf("CheckpointInt64 = (%d, %v), want (%d, %v)", got, ok, tc.want, tc.wantOK) + } + }) + } +} + +func TestCheckpointFloat64(t *testing.T) { + cp := CheckpointData{"f": int64(7), "g": float64(7.5), "s": "7.5"} + if v, ok := CheckpointFloat64(cp, "f"); !ok || v != 7 { + t.Errorf("int64 -> float64: got (%v, %v)", v, ok) + } + if v, ok := CheckpointFloat64(cp, "g"); !ok || v != 7.5 { + t.Errorf("float64: got (%v, %v)", v, ok) + } + if v, ok := CheckpointFloat64(cp, "s"); !ok || v != 7.5 { + t.Errorf("string -> float64: got (%v, %v)", v, ok) + } + if _, ok := CheckpointFloat64(cp, "missing"); ok { + t.Error("missing key should return ok=false") + } +} + +// TestCheckpointInt64_RoundTrip reproduces the crash scenario: a checkpoint +// value is written as an in-memory int64 on cycle N, then read on cycle N+1. +// The previous bare .(float64) assertion panicked on the in-memory path. This +// asserts both the in-memory and the JSON-persisted paths read back identically. +func TestCheckpointInt64_RoundTrip(t *testing.T) { + const recordID int64 = 123456 + + // In-memory path (map kept between cycles, value stays int64). + inMem := CheckpointData{"security_record_id": recordID} + if v, ok := CheckpointInt64(inMem, "security_record_id"); !ok || v != recordID { + t.Fatalf("in-memory read: got (%d, %v), want (%d, true)", v, ok, recordID) + } + + // JSON-persisted path (value comes back as float64). + data, err := json.Marshal(inMem) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var restored CheckpointData + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if v, ok := CheckpointInt64(restored, "security_record_id"); !ok || v != recordID { + t.Fatalf("json-restored read: got (%d, %v), want (%d, true)", v, ok, recordID) + } +} diff --git a/internal/auditlogs/collector/collector_windows.go b/internal/auditlogs/collector/collector_windows.go index d73ceb4..3ac0881 100644 --- a/internal/auditlogs/collector/collector_windows.go +++ b/internal/auditlogs/collector/collector_windows.go @@ -309,10 +309,11 @@ func (c *windowsCollector) queryChannel(ctx context.Context, ch eventChannel, ch } query := ch.query - if recordID, ok := checkpoint[ch.checkpointKey()]; ok { - if rid := int64(recordID.(float64)); rid > 0 { // JSON decode - query = buildXPathQuery(ch.query, rid) - } + // Tolerant accessor: the checkpoint value is an in-memory int64 between + // cycles and a float64 after JSON persistence. A bare .(float64) assertion + // panicked on the in-memory int64 path and crashed the agent. + if rid, ok := CheckpointInt64(checkpoint, ch.checkpointKey()); ok && rid > 0 { + query = buildXPathQuery(ch.query, rid) } channelPath, err := windows.UTF16PtrFromString(ch.name) diff --git a/internal/auth/enhanced_auth.go b/internal/auth/enhanced_auth.go index 4274370..eab3242 100644 --- a/internal/auth/enhanced_auth.go +++ b/internal/auth/enhanced_auth.go @@ -9,6 +9,7 @@ import ( "time" "sentinelgo/internal/config" + authsvc "sentinelgo/internal/service/auth" ) // EnhancedAuth provides authentication with circuit breaker and exponential backoff @@ -26,41 +27,39 @@ func NewEnhancedAuth() *EnhancedAuth { } } +// tokenRefreshSkew is how far before the JWT's expiry a preemptive refresh is +// triggered. +const tokenRefreshSkew = 5 * time.Minute + // ValidateAndRefreshTokens validates current tokens and refreshes if needed func (ea *EnhancedAuth) ValidateAndRefreshTokens(ctx context.Context, cfg *config.Config, tokenRefreshFunc func(context.Context, *config.Config) error) error { ea.mu.Lock() defer ea.mu.Unlock() - // Check if we need to validate tokens (don't check too frequently) - if time.Since(ea.lastTokenCheck) < 30*time.Second { + // Check if we need to validate tokens (don't check too frequently). Read the + // previous check time BEFORE stamping the new one — the old code stamped it + // first, which made the expiry branch below permanently unreachable. + if !ea.lastTokenCheck.IsZero() && time.Since(ea.lastTokenCheck) < 30*time.Second { return nil } ea.lastTokenCheck = time.Now() log.Printf("Validating authentication tokens") - // Pre-emptive refresh if token is close to expiration + // Pre-emptive refresh if the token is at/near expiration. if ea.shouldRefreshToken(cfg) { - log.Printf("Token close to expiration, attempting preemptive refresh") + log.Printf("Token at/near expiration, attempting preemptive refresh") return ea.refreshWithRetry(ctx, cfg, tokenRefreshFunc) } return nil } -// shouldRefreshToken determines if token should be refreshed preemptively +// shouldRefreshToken determines if the token should be refreshed preemptively, +// based on the JWT's actual `exp` claim rather than a time-since-last-check +// guess. func (ea *EnhancedAuth) shouldRefreshToken(cfg *config.Config) bool { - // Simple heuristic: refresh if access token is empty or if it's been a while since last refresh - if cfg.AccessToken == "" { - return true - } - - // If we haven't refreshed in 23 hours, do it preemptively (tokens typically last 24h) - if ea.lastTokenCheck.IsZero() || time.Since(ea.lastTokenCheck) > 23*time.Hour { - return true - } - - return false + return authsvc.ShouldRefresh(cfg.AccessToken, tokenRefreshSkew) } // refreshWithRetry attempts to refresh token with exponential backoff diff --git a/internal/config/config.go b/internal/config/config.go index 6a5d700..46a52fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" ) @@ -112,6 +113,36 @@ type Config struct { SoftwareSyncEnabled bool `json:"software_sync_enabled"` // Enable software synchronization AuditLogsEnabled bool `json:"audit_logs_enabled"` // Enable audit logs service EdgeFunctionURL string `json:"edge_function_url"` // Edge Function URL for software data + + // tokenMu guards concurrent token writes (SetTokens) and serialises + // SaveAtomic. Token refresh runs in its own goroutine while heartbeat, + // software-sync, and upload tasks read the tokens; without this they race, + // and two concurrent SaveAtomic calls could collide on the temp file and + // persist a torn config. Unexported, so it is ignored by JSON (un)marshal. + tokenMu sync.Mutex +} + +// SetTokens atomically replaces the access and refresh tokens in memory. +func (c *Config) SetTokens(accessToken, refreshToken string) { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + c.AccessToken = accessToken + c.RefreshToken = refreshToken +} + +// GetAccessToken returns the current access token under lock. Use this from +// concurrent task goroutines rather than reading the field directly. +func (c *Config) GetAccessToken() string { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + return c.AccessToken +} + +// GetRefreshToken returns the current refresh token under lock. +func (c *Config) GetRefreshToken() string { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + return c.RefreshToken } // GetSoftwareInfoUpdateInterval returns software update interval as time.Duration @@ -172,11 +203,11 @@ func Load(path string) (*Config, error) { GitHubOwner: "BrainStation-23", GitHubRepo: "SentinelGo", CurrentVersion: Version, // Use injected version - AutoUpdate: true, // Disabled by default for safety + AutoUpdate: true, // Enabled by default; updates require a published SHA256 checksum and a semver-newer release (see internal/updater) AutoUpdateInterval: Duration(24 * time.Hour), AgentInfoUpdateInterval: Duration(time.Hour), TaskPollingInterval: Duration(5 * time.Minute), - EnableTaskPolling: true, // Disabled by default for safety + EnableTaskPolling: true, // Enabled by default; remote task execution is gated by backend RLS (and, as a follow-up, task-script signing) // Supabase configuration — SupabaseURL has no default; it must be set in config.json. AgentSecret: "", // Log collection configuration @@ -191,9 +222,15 @@ func Load(path string) (*Config, error) { cfg.Path = GetDefaultConfigPath() // Ensure config directory exists configDir := filepath.Dir(cfg.Path) - if err := os.MkdirAll(configDir, 0750); err != nil { + if err := os.MkdirAll(configDir, 0700); err != nil { return nil, fmt.Errorf("failed to create config directory: %v", err) } + // Harden the directory holding the agent's secrets (SYSTEM/Admins-only + // DACL on Windows; 0700 on Unix). Best-effort: log-and-continue so a + // permissions failure doesn't prevent the agent from starting. + if err := secureDir(configDir); err != nil { + log.Printf("warning: failed to secure config directory %s: %v", configDir, err) + } } // Validate and sanitize config path to prevent path traversal @@ -221,6 +258,15 @@ func Load(path string) (*Config, error) { } } + // Reconcile the reported version with the version actually running. The + // compiled-in Version (injected via -ldflags) is the authoritative source of + // truth for what binary is executing; the persisted current_version can drift + // (e.g. if a self-update swap failed after the version was written). Asserting + // the real version here means a failed update is detected and retried on the + // next check instead of the agent silently reporting a version it isn't + // running. See internal/updater.CheckAndApply. + cfg.CurrentVersion = Version + // Ensure DeviceID exists if cfg.DeviceID == "" { cfg.DeviceID = generateDeviceID() @@ -265,8 +311,13 @@ func (c *Config) Save() error { return os.WriteFile(c.Path, data, 0600) } -// SaveAtomic saves config atomically to prevent corruption +// SaveAtomic saves config atomically to prevent corruption. It serialises with +// token writes and uses a unique temp file per call so concurrent saves cannot +// collide on a fixed ".tmp" path and persist a torn/interleaved config. func (c *Config) SaveAtomic() error { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + dir := filepath.Dir(c.Path) if err := os.MkdirAll(dir, 0750); err != nil { return err @@ -283,9 +334,31 @@ func (c *Config) SaveAtomic() error { return err } - // Create temporary file - tempPath := c.Path + ".tmp" - if err := os.WriteFile(tempPath, data, 0600); err != nil { + // Unique temp file in the same directory (so the rename stays on one device). + tmp, err := os.CreateTemp(dir, "config-*.tmp") + if err != nil { + return err + } + tempPath := tmp.Name() + cleanup := func() { + _ = tmp.Close() + if removeErr := os.Remove(tempPath); removeErr != nil && !os.IsNotExist(removeErr) { + log.Printf("failed to remove temp file %s: %v", tempPath, removeErr) + } + } + + if _, err := tmp.Write(data); err != nil { + cleanup() + return err + } + // Restrict to owner read/write (effective on Unix; Windows ACLs are applied + // to the final file via secureConfigFile below). + if err := tmp.Chmod(0600); err != nil { + cleanup() + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tempPath) return err } @@ -297,6 +370,12 @@ func (c *Config) SaveAtomic() error { return err } + // Harden on-disk permissions for the secrets file (no-op on Unix where the + // 0600 mode already applies; sets an explicit DACL on Windows). + if err := secureConfigFile(c.Path); err != nil { + log.Printf("warning: failed to secure config file permissions: %v", err) + } + return nil } diff --git a/internal/config/save_concurrency_test.go b/internal/config/save_concurrency_test.go new file mode 100644 index 0000000..48ec948 --- /dev/null +++ b/internal/config/save_concurrency_test.go @@ -0,0 +1,57 @@ +package config + +import ( + "path/filepath" + "sync" + "testing" + "time" +) + +// newSaveableConfig builds a minimal config that passes validateConfig so +// SaveAtomic can run, writing to a temp path. +func newSaveableConfig(t *testing.T) *Config { + t.Helper() + return &Config{ + Path: filepath.Join(t.TempDir(), "config.json"), + SupabaseURL: "https://example.supabase.co", + SupabaseKey: "anon-key", + DeviceID: "dev-1", + UpdateInterval: Duration(5 * time.Minute), + LogFlushInterval: Duration(5 * time.Minute), + AccessToken: "initial", + RefreshToken: "initial", + } +} + +// TestSaveAtomic_ConcurrentSavesAndTokenWrites exercises concurrent SaveAtomic +// and SetTokens calls. Under -race this fails if the token writes or the temp +// file handling are not properly synchronised (the old fixed ".tmp" path and +// unguarded field writes raced). +func TestSaveAtomic_ConcurrentSavesAndTokenWrites(t *testing.T) { + cfg := newSaveableConfig(t) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(2) + go func(n int) { + defer wg.Done() + cfg.SetTokens("access", "refresh") + }(i) + go func() { + defer wg.Done() + if err := cfg.SaveAtomic(); err != nil { + t.Errorf("SaveAtomic: %v", err) + } + }() + } + wg.Wait() + + // File must be loadable and intact (not torn) after the concurrent churn. + loaded, err := Load(cfg.Path) + if err != nil { + t.Fatalf("Load after concurrent saves: %v", err) + } + if loaded.SupabaseURL != "https://example.supabase.co" { + t.Errorf("config corrupted: SupabaseURL = %q", loaded.SupabaseURL) + } +} diff --git a/internal/config/secure_unix.go b/internal/config/secure_unix.go new file mode 100644 index 0000000..4d2bf65 --- /dev/null +++ b/internal/config/secure_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package config + +import "os" + +// secureConfigFile ensures the config file is owner-read/write only. On Unix the +// 0600 mode set when the file is written already enforces this; this re-asserts +// it defensively (e.g. if the file pre-existed with looser permissions). +func secureConfigFile(path string) error { + return os.Chmod(path, 0600) +} + +// secureDir ensures the directory is owner-only (0700) on Unix. +func secureDir(path string) error { + return os.Chmod(path, 0700) +} diff --git a/internal/config/secure_windows.go b/internal/config/secure_windows.go new file mode 100644 index 0000000..20bdc61 --- /dev/null +++ b/internal/config/secure_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package config + +import "sentinelgo/internal/winsec" + +// secureConfigFile applies an explicit DACL to the config file so only SYSTEM, +// the local Administrators group, and the account running the agent can read or +// modify it. +// +// On Windows, Go's os.Chmod only toggles the read-only bit — it does NOT set a +// restrictive ACL. The config holds the agent's access token, refresh token and +// agent secret in cleartext; under the default ACL inherited from the drive +// root, the local Users group can read it. This locks it down explicitly. +func secureConfigFile(path string) error { + return winsec.SecurePath(path) +} + +// secureDir applies the same restrictive DACL to the directory holding the +// config so child items do not inherit broader permissions. +func secureDir(path string) error { + return winsec.SecurePath(path) +} diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 7a83e8c..82e2fa6 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -7,7 +7,6 @@ import ( "runtime" "strconv" "strings" - "syscall" "time" ) @@ -138,17 +137,20 @@ func (lf *LockFile) Release() error { return nil } - // Remove the lock file - if err := os.Remove(lf.path); err != nil { + // Close the file BEFORE removing it. On Windows an open file cannot be + // removed, so closing first ensures the lock file is actually deleted on + // all platforms. + if err := lf.file.Close(); err != nil { // Log error but continue _ = err } - // Close the file - if err := lf.file.Close(); err != nil { + // Remove the lock file + if err := os.Remove(lf.path); err != nil { // Log error but continue _ = err } + lf.file = nil lf.acquired = false @@ -171,24 +173,14 @@ func (lf *LockFile) GetLockedPID() (int, error) { return pid, nil } -// IsProcessRunning checks if a process with the given PID is running -func IsProcessRunning(pid int) bool { - process, err := os.FindProcess(pid) - if err != nil { - return false - } - - if runtime.GOOS == "windows" { - defer func() { _ = process.Release() }() - // On Windows, Signal(os.Kill) succeeds (nil error) when the process exists. - return process.Signal(os.Kill) == nil - } - - // For Unix systems (Linux/macOS), use Signal(0) to check if process exists - // This is a non-lethal signal that just checks if the process is reachable - err = process.Signal(syscall.Signal(0)) - return err == nil -} +// IsProcessRunning checks if a process with the given PID is running. +// +// The implementation is platform-specific (see lockfile_windows.go and +// lockfile_unix.go) and is guaranteed to be NON-DESTRUCTIVE: it never sends a +// terminating signal to the inspected process. The previous Windows +// implementation used Signal(os.Kill), which terminates the target rather than +// probing it — a stale-lock check could kill the running agent or, on PID +// reuse, an unrelated process. // isProcessRunning checks if the process in the lock file is still running func (lf *LockFile) isProcessRunning() bool { diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go index e595bd4..b4ca686 100644 --- a/internal/lockfile/lockfile_test.go +++ b/internal/lockfile/lockfile_test.go @@ -2,6 +2,7 @@ package lockfile_test import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -32,9 +33,6 @@ func TestTryAcquire_Basic(t *testing.T) { } func TestTryAcquire_AlreadyHeld(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("IsProcessRunning uses Signal(os.Kill) on Windows which would kill the test process") - } path := lockPath(t) lf1 := lockfile.NewLockFileWithPath(path) @@ -55,9 +53,6 @@ func TestTryAcquire_AlreadyHeld(t *testing.T) { } func TestRelease_RemovesFile(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Windows cannot os.Remove an open file; Release() closes then removes, so file persists on error — Unix-only check") - } path := lockPath(t) lf := lockfile.NewLockFileWithPath(path) if err := lf.TryAcquire(); err != nil { @@ -74,9 +69,6 @@ func TestRelease_RemovesFile(t *testing.T) { } func TestRelease_AllowsReacquire(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Release leaves file on Windows (cannot remove open file); re-acquire then triggers Signal(os.Kill) on current PID") - } path := lockPath(t) lf1 := lockfile.NewLockFileWithPath(path) @@ -127,9 +119,6 @@ func TestAcquireWithTimeout_Success(t *testing.T) { } func TestAcquireWithTimeout_Timeout(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("IsProcessRunning uses Signal(os.Kill) on Windows which would kill the test process") - } path := lockPath(t) lf1 := lockfile.NewLockFileWithPath(path) @@ -183,9 +172,6 @@ func TestCheckExistingLock_StaleFile(t *testing.T) { } func TestCheckExistingLock_ActiveLock(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("IsProcessRunning uses Signal(os.Kill) on Windows which would kill the test process") - } path := lockPath(t) lf := lockfile.NewLockFileWithPath(path) @@ -205,14 +191,31 @@ func TestCheckExistingLock_ActiveLock(t *testing.T) { } func TestIsProcessRunning_CurrentProcess(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Signal(os.Kill) on Windows terminates the target process — cannot safely probe current PID") - } if !lockfile.IsProcessRunning(os.Getpid()) { t.Errorf("IsProcessRunning(%d) = false, want true (current process)", os.Getpid()) } } +func TestIsProcessRunning_ExitedProcess(t *testing.T) { + // Start a short-lived child, wait for it to exit, then confirm the probe + // reports it as not running — and critically, never terminates anything. + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "exit", "0") + } else { + cmd = exec.Command("true") + } + if err := cmd.Start(); err != nil { + t.Skipf("could not start helper process: %v", err) + } + pid := cmd.Process.Pid + _ = cmd.Wait() // ensure it has fully exited + + if lockfile.IsProcessRunning(pid) { + t.Errorf("IsProcessRunning(%d) = true, want false (exited process)", pid) + } +} + func TestIsProcessRunning_NonExistent(t *testing.T) { // 99999999 is not a multiple of 4, so it cannot be a valid Windows PID. // On all platforms, this PID should not exist in practice. diff --git a/internal/lockfile/lockfile_unix.go b/internal/lockfile/lockfile_unix.go new file mode 100644 index 0000000..7420953 --- /dev/null +++ b/internal/lockfile/lockfile_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package lockfile + +import ( + "os" + "syscall" +) + +// IsProcessRunning reports whether a process with the given PID is currently +// running. On Unix it uses signal 0, which performs the kernel's permission and +// existence checks without delivering an actual signal — it is non-destructive. +func IsProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + return process.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/lockfile/lockfile_windows.go b/internal/lockfile/lockfile_windows.go new file mode 100644 index 0000000..2d1bc8a --- /dev/null +++ b/internal/lockfile/lockfile_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package lockfile + +import "golang.org/x/sys/windows" + +// stillActive is the value GetExitCodeProcess reports for a process that has +// not yet terminated (STILL_ACTIVE / STATUS_PENDING). +const stillActive = 259 + +// waitTimeout is the value WaitForSingleObject returns when the wait expired +// before the object was signaled (WAIT_TIMEOUT, 0x102) — i.e. the process is +// still running. +const waitTimeout = uint32(0x102) + +// IsProcessRunning reports whether a process with the given PID is currently +// running, WITHOUT affecting it. It opens a query-only handle and inspects the +// process state; it never terminates the target. +func IsProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + // Process does not exist, or we lack rights to query it. Treat the + // common case (no such process) as "not running". + return false + } + defer func() { _ = windows.CloseHandle(h) }() + + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return false + } + if code != stillActive { + return false + } + + // Disambiguate the rare case where a terminated process exited with code + // 259: a still-running process leaves WaitForSingleObject in WAIT_TIMEOUT, + // whereas a terminated one is already signaled. + event, err := windows.WaitForSingleObject(h, 0) + if err != nil { + // Fall back to the exit-code result if the wait could not be performed. + return true + } + return event == waitTimeout +} diff --git a/internal/logging/collection_internal_test.go b/internal/logging/collection_internal_test.go new file mode 100644 index 0000000..60fa35e --- /dev/null +++ b/internal/logging/collection_internal_test.go @@ -0,0 +1,127 @@ +package logging + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "sentinelgo/internal/auditlogs/collector" + "sentinelgo/internal/auditlogs/parser" + "sentinelgo/internal/config" + "sentinelgo/internal/store" +) + +// fakeCollector returns a fixed sequence of batches, then empty. It advances a +// simple counter checkpoint so runCollection's drain loop terminates. +type fakeCollector struct { + batches [][]collector.RawLogEntry + idx int + calls int +} + +func (f *fakeCollector) Collect(_ context.Context, _ collector.CheckpointData) ([]collector.RawLogEntry, collector.CheckpointData, error) { + f.calls++ + if f.idx >= len(f.batches) { + return nil, collector.CheckpointData{"n": int64(f.idx)}, nil + } + b := f.batches[f.idx] + f.idx++ + return b, collector.CheckpointData{"n": int64(f.idx)}, nil +} + +func (f *fakeCollector) Subscribe(_ context.Context, _ chan<- collector.RawLogEntry) error { + return nil +} + +func (f *fakeCollector) Sources() []string { return []string{"fake"} } + +func makeBatch(prefix string, n int, ts time.Time) []collector.RawLogEntry { + out := make([]collector.RawLogEntry, n) + for i := 0; i < n; i++ { + out[i] = collector.RawLogEntry{ + Timestamp: ts, + Source: "fake", + EventID: fmt.Sprintf("%s-%d", prefix, i), + RawMessage: fmt.Sprintf("%s message %d", prefix, i), + Severity: "info", + Metadata: map[string]string{}, + } + } + return out +} + +func newTestIntegration(t *testing.T, fc collector.Collector) *LoggingIntegration { + t.Helper() + dir := t.TempDir() + st, err := store.NewAuditLogStore(filepath.Join(dir, "audit.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + cpStore := NewCheckpointStore(dir) + if err := cpStore.Load(); err != nil { + t.Fatalf("load checkpoint: %v", err) + } + + return &LoggingIntegration{ + cfg: &config.Config{DeviceID: "dev", LogFlushInterval: config.Duration(5 * time.Minute)}, + collector: fc, + parser: parser.NewParser(), + store: st, + checkpoint: cpStore, + } +} + +// TestRunCollection_DrainsAllBatches proves the burst-loss fix (H1/H6): a burst +// spanning multiple batches is fully drained into the store, not truncated to a +// single per-cycle cap. +func TestRunCollection_DrainsAllBatches(t *testing.T) { + now := time.Now() + fc := &fakeCollector{batches: [][]collector.RawLogEntry{ + makeBatch("a", 1500, now), + makeBatch("b", 1500, now), + makeBatch("c", 200, now), + }} + li := newTestIntegration(t, fc) + + if err := li.runCollection(context.Background()); err != nil { + t.Fatalf("runCollection: %v", err) + } + + pending, err := li.store.GetPending(0) // 0 = no limit + if err != nil { + t.Fatalf("GetPending: %v", err) + } + if len(pending) != 3200 { + t.Errorf("stored %d entries, want 3200 (burst must not be truncated)", len(pending)) + } + if v, ok := li.checkpoint.Get()["n"]; !ok || v.(int64) != 3 { + t.Errorf("checkpoint not advanced past all batches: %v", li.checkpoint.Get()) + } +} + +// TestRunCollection_KeepsOldBacklog proves the downtime-loss fix (H1/H6): +// entries older than the flush window are NOT discarded. Previously the +// time-window filter dropped them after advancing the checkpoint past them. +func TestRunCollection_KeepsOldBacklog(t *testing.T) { + twoHoursAgo := time.Now().Add(-2 * time.Hour) // well outside the 5m flush window + fc := &fakeCollector{batches: [][]collector.RawLogEntry{ + makeBatch("old", 50, twoHoursAgo), + }} + li := newTestIntegration(t, fc) + + if err := li.runCollection(context.Background()); err != nil { + t.Fatalf("runCollection: %v", err) + } + + pending, err := li.store.GetPending(0) + if err != nil { + t.Fatalf("GetPending: %v", err) + } + if len(pending) != 50 { + t.Errorf("stored %d old entries, want 50 (downtime backlog must be kept, not discarded)", len(pending)) + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index de2592c..2103580 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -260,68 +260,87 @@ func (li *LoggingIntegration) subscriptionLoop(ctx context.Context) { } } -// runCollection performs a single batch collection cycle with time-window bounding. +// maxDrainIterations bounds how many collect batches a single cycle will pull. +// It is a safety backstop against a collector that never reports "drained" +// (e.g. a source on a very busy host, or one whose checkpoint fails to advance). +// When the cap is hit, the persisted checkpoint guarantees the remaining backlog +// is picked up on the next cycle — nothing is lost, it is only deferred. +const maxDrainIterations = 100 + +// runCollection performs a single collection cycle, draining ALL entries +// available since the last checkpoint into the local SQLite queue. // -// It records T = now and windowStart = T - flushInterval, collects raw entries from the -// OS since the last checkpoint, then discards any entry with Timestamp before windowStart. -// The checkpoint is advanced to the latest record position of ALL collected entries -// (including those outside the window) so they are not re-fetched on the next cycle. +// Correctness notes (audit fixes H1/H6 — this is a compliance agent, audit +// events must not be silently dropped): +// - There is NO time-window filter. The previous implementation discarded any +// entry older than now-flushInterval AFTER advancing the checkpoint past it, +// so a downtime backlog (agent offline > flushInterval) was permanently lost. +// The checkpoint/cursor is the single source of "what is new". +// - The checkpoint is advanced ONLY after entries are durably stored, so a +// store failure re-collects rather than skips. The SQLite store is the +// at-least-once queue; the uploader drains it independently. +// - Bursts larger than one batch are drained in a loop instead of being +// truncated to a fixed cap, so high-volume periods are not silently dropped. func (li *LoggingIntegration) runCollection(ctx context.Context) error { - collectionTime := time.Now() - windowStart := collectionTime.Add(-li.cfg.GetLogFlushInterval()) + totalCollected := 0 + totalStored := 0 + cappedOut := false - cp := li.checkpoint.Get() - collectorCP := make(collector.CheckpointData, len(cp)) - for k, v := range cp { - collectorCP[k] = v - } + for iter := 0; iter < maxDrainIterations; iter++ { + if ctx.Err() != nil { + return ctx.Err() + } - entries, newCP, err := li.collector.Collect(ctx, collectorCP) - if err != nil { - return fmt.Errorf("collect: %w", err) - } + collectorCP := collector.CheckpointData(li.checkpoint.Get()) - if len(entries) == 0 { - return nil - } + entries, newCP, err := li.collector.Collect(ctx, collectorCP) + if err != nil { + return fmt.Errorf("collect: %w", err) + } + if len(entries) == 0 { + break // drained: nothing new since the checkpoint + } - // Advance checkpoint over ALL fetched entries so we don't re-read them next cycle. - updatedCP := make(CheckpointData, len(newCP)) - for k, v := range newCP { - updatedCP[k] = v - } - li.checkpoint.Update(updatedCP) + // Parse and persist BEFORE advancing the checkpoint. The store dedups by + // hash, so a re-collect after a crash between Insert and checkpoint Save + // produces no duplicates. + parsed := li.parser.Parse(entries, li.parserConfig()) + if err := li.store.Insert(parsed); err != nil { + return fmt.Errorf("store insert: %w", err) + } + + // Advance the checkpoint past entries now durably stored. Update merges, + // so per-source keys absent from newCP are preserved. + li.checkpoint.Update(CheckpointData(newCP)) + + totalCollected += len(entries) + totalStored += len(parsed) - // Bound to the time window: discard entries older than windowStart. - filtered := entries[:0] - for _, e := range entries { - if !e.Timestamp.Before(windowStart) { - filtered = append(filtered, e) + if iter == maxDrainIterations-1 { + cappedOut = true } } - if len(filtered) == 0 { - log.Printf("[logging] collected %d entries, all outside time window — skipping parse", len(entries)) + if totalCollected == 0 { return nil } - // Cap to per-cycle limit after time-window filter. - if len(filtered) > defaultMaxEntriesPerCycle { - filtered = filtered[:defaultMaxEntriesPerCycle] - } - - parsed := li.parser.Parse(filtered, li.parserConfig()) - if err := li.store.Insert(parsed); err != nil { - return fmt.Errorf("store insert: %w", err) + // Persist the checkpoint now that entries are in the durable store, decoupled + // from upload success — the uploader retries from the store independently. + if err := li.checkpoint.Save(); err != nil { + log.Printf("[logging] checkpoint save error after collection: %v", err) } - li.stats.addCollected(int64(len(entries))) - li.stats.addStored(int64(len(parsed))) + li.stats.addCollected(int64(totalCollected)) + li.stats.addStored(int64(totalStored)) - log.Printf("[logging] collected %d entries, %d in window [%s, %s], %d stored", - len(entries), len(filtered), - windowStart.Format(time.RFC3339), collectionTime.Format(time.RFC3339), - len(parsed)) + if cappedOut { + log.Printf("[logging] collected %d entries, %d stored (drain cap of %d batches hit; "+ + "remaining backlog continues next cycle, checkpoint persisted — no loss)", + totalCollected, totalStored, maxDrainIterations) + } else { + log.Printf("[logging] collected %d entries, %d stored", totalCollected, totalStored) + } return nil } diff --git a/internal/logging/uploader.go b/internal/logging/uploader.go index e24b53f..212ac76 100644 --- a/internal/logging/uploader.go +++ b/internal/logging/uploader.go @@ -206,13 +206,21 @@ func (u *Uploader) UploadFromStore(ctx context.Context, auditStore *store.AuditL break } + // These rows were uploaded successfully; count them regardless of the + // delete outcome. + totalUploaded += len(logs) + u.stats.addUploaded(int64(len(logs))) + if err := auditStore.DeleteByIDs(ids); err != nil { - log.Printf("[uploader] failed to delete uploaded logs from store: %v", err) - // Rows were uploaded successfully; best-effort delete — don't block the loop. + // CRITICAL: do NOT continue the loop on delete failure. The same rows + // are still pending, so the next GetPending would return them again and + // we would re-upload the identical batch in a tight loop for as long as + // the DB error persists. Stop the cycle; the next scheduled cycle retries. + lastErr = fmt.Errorf("delete uploaded logs from store: %w", err) + log.Printf("[uploader] %v — stopping cycle to avoid re-uploading the same rows", lastErr) + break } - totalUploaded += len(logs) - u.stats.addUploaded(int64(len(logs))) log.Printf("[uploader] uploaded and removed %d logs from store", len(logs)) } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 796b529..5631d90 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -141,6 +141,21 @@ func (s *Scheduler) Start(cfg *config.Config, authSvc *authsvc.Service) error { return nil } +// runTaskHandler invokes a task handler with panic recovery. A panic in a +// collector or handler (e.g. a nil dereference or a type-assertion bug) would +// otherwise unwind through the task goroutine and crash the entire agent. Here +// it is logged and converted to an error so the one task fails while the rest of +// the agent keeps running. +func runTaskHandler(ctx context.Context, name string, h TaskHandler, cfg *config.Config, authSvc *authsvc.Service) (err error) { + defer func() { + if r := recover(); r != nil { + log.Printf("Task %s panicked (recovered): %v", sanitize.ForLog(name), r) + err = fmt.Errorf("task %s panicked: %v", name, r) + } + }() + return h(ctx, cfg, authSvc) +} + // runInitialTasks executes all enabled tasks once in dependency order using a // single background goroutine so the ordering is respected without blocking // Scheduler.Start. If a task fails its LastRun is still recorded so dependent @@ -170,7 +185,7 @@ func (s *Scheduler) runInitialTasks(cfg *config.Config, authSvc *authsvc.Service } log.Printf("Running initial task: %s", taskName) - if err := task.Handler(s.ctx, cfg, authSvc); err != nil { + if err := runTaskHandler(s.ctx, taskName, task.Handler, cfg, authSvc); err != nil { log.Printf("Initial task %s failed: %v", taskName, err) } else { log.Printf("Initial task %s completed successfully", taskName) @@ -236,7 +251,7 @@ func (s *Scheduler) runPeriodicTasks(cfg *config.Config, authSvc *authsvc.Servic defer t.Running.Store(false) log.Printf("Running periodic task: %s", taskName) - if err := t.Handler(s.ctx, cfg, authSvc); err != nil { + if err := runTaskHandler(s.ctx, taskName, t.Handler, cfg, authSvc); err != nil { log.Printf("Periodic task %s failed: %v", taskName, err) } else { log.Printf("Periodic task %s completed successfully", taskName) @@ -336,6 +351,16 @@ type TaskStatus struct { // service and is NOT included here. func CreateDefaultTasks() []*Task { return []*Task{ + { + // Proactively refresh the JWT well before it expires. Without this, + // the access token (typically ~1h) lapsed silently after startup and + // every reporting path stopped while the process still looked healthy. + Name: "token-refresh", + Interval: time.Minute, + Dependencies: []string{}, + Handler: handleTokenRefresh, + Enabled: true, + }, { Name: "auto-update", Interval: 24 * time.Hour, // overridden from config.AutoUpdateInterval @@ -361,15 +386,37 @@ func CreateDefaultTasks() []*Task { } // Task handlers + +// tokenRefreshSkew is how far before expiry the access token is proactively +// refreshed. With a ~1h Supabase token this leaves comfortable margin. +const tokenRefreshSkew = 5 * time.Minute + +func handleTokenRefresh(ctx context.Context, cfg *config.Config, authSvc *authsvc.Service) error { + if authSvc == nil { + return nil + } + if !authsvc.ShouldRefresh(cfg.AccessToken, tokenRefreshSkew) { + return nil + } + log.Printf("Scheduler: access token at/near expiry, refreshing proactively") + return authSvc.RefreshToken(ctx, cfg) +} + func handleAutoUpdate(ctx context.Context, cfg *config.Config, authSvc *authsvc.Service) error { log.Printf("Running auto-update check") // Use retry wrapper for auto-update return updater.CheckAndApplyWithRetry(ctx, cfg, "") } -func handleAgentInfoUpdate(ctx context.Context, cfg *config.Config, authSvc *authsvc.Service) error { +func handleAgentInfoUpdate(ctx context.Context, cfg *config.Config, _ *authsvc.Service) error { log.Printf("Running agent info update") sysInfo := osinfo.Collect() + if sysInfo == nil { + // osinfo.Collect returns nil when host.Info() fails. Skip this cycle + // rather than dereferencing nil downstream (which panicked the task + // goroutine and, with no recover, killed the whole agent). + return fmt.Errorf("system info collection returned no data; skipping this cycle") + } agentSvc := agentsvc.NewAgentService() return agentSvc.UpdateAgentInfo(ctx, cfg, sysInfo) } diff --git a/internal/service/agent/agent.go b/internal/service/agent/agent.go index 74a892b..de9166e 100644 --- a/internal/service/agent/agent.go +++ b/internal/service/agent/agent.go @@ -4,13 +4,19 @@ import ( "context" "encoding/json" "fmt" + "time" "sentinelgo/internal/config" "sentinelgo/internal/osinfo/shared" + "sentinelgo/internal/service/rpcutil" postgrest "github.com/supabase-community/postgrest-go" ) +// rpcTimeout bounds a single Supabase RPC so a hung connection cannot wedge the +// scheduled task that calls it. +const rpcTimeout = 60 * time.Second + // AgentUpdatePayload represents data structure for updating agent information type AgentUpdatePayload struct { Status string `json:"status"` @@ -97,10 +103,15 @@ func (s *AgentService) UpdateAgentInfo(ctx context.Context, cfg *config.Config, SecurityInfo: sysInfo.SecurityInfo, } - client := newPostgrestClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.AccessToken) - rawResult := client.Rpc("agent_push_inventory", "", map[string]interface{}{ - "payload": payload, + client := newPostgrestClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.GetAccessToken()) + rawResult, err := rpcutil.CallWithTimeout(ctx, rpcTimeout, func() (string, error) { + return client.Rpc("agent_push_inventory", "", map[string]interface{}{ + "payload": payload, + }), client.ClientError }) + if err != nil { + return fmt.Errorf("call agent_push_inventory RPC: %w", err) + } if rawResult == "" { return fmt.Errorf("call agent_push_inventory RPC: empty response") } @@ -120,7 +131,7 @@ func (s *AgentService) UpdateAgentInfo(ctx context.Context, cfg *config.Config, // SetAgentStatus updates only the agent status column via the PostgREST SDK. func (s *AgentService) SetAgentStatus(ctx context.Context, cfg *config.Config, status string) error { - client := newPostgrestClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.AccessToken) + client := newPostgrestClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.GetAccessToken()) _, _, err := client.From("agents"). Update(map[string]string{"status": status}, "minimal", ""). ExecuteWithContext(ctx) @@ -133,7 +144,7 @@ func (s *AgentService) SetAgentStatus(ctx context.Context, cfg *config.Config, s // GetAgentInfo retrieves agent information from the agents table via the // PostgREST SDK. func (s *AgentService) GetAgentInfo(ctx context.Context, cfg *config.Config) (map[string]interface{}, error) { - client := newPostgrestClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.AccessToken) + client := newPostgrestClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.GetAccessToken()) var agents []map[string]interface{} _, err := client.From("agents"). diff --git a/internal/service/auditlog/auditlog.go b/internal/service/auditlog/auditlog.go index 1a21be5..5bf795d 100644 --- a/internal/service/auditlog/auditlog.go +++ b/internal/service/auditlog/auditlog.go @@ -206,7 +206,11 @@ func (s *AuditLogService) SendBatchLogsWithContext(ctx context.Context, batchDat return fmt.Errorf("marshal batch data: %w", err) } - log.Printf("request data from Audit Service: %s", string(data)) + // Log only the size, never the payload: a batch can contain up to 500 audit + // events including usernames, hostnames and process command lines. Dumping + // them into the agent's own log leaks sensitive data into logs of unknown + // retention/permissions and bloats them. + log.Printf("Audit Service: uploading batch (%d bytes)", len(data)) req, err := s.newRequest(ctx, data) if err != nil { diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 82e5aa0..5fa414e 100644 --- a/internal/service/auth/auth.go +++ b/internal/service/auth/auth.go @@ -27,6 +27,10 @@ type Service struct { // refreshCh is non-nil while a refresh is in progress. // Latecomers wait on this channel rather than starting a second refresh. refreshCh chan struct{} + // refreshErr holds the outcome of the most recent refresh, published by the + // leader before it closes refreshCh so waiters return the real result rather + // than a false success. + refreshErr error } // NewService creates a new authentication service. apiKey is used for the @@ -86,13 +90,19 @@ func (s *Service) RefreshToken(ctx context.Context, cfg *config.Config) error { s.refreshMu.Lock() if s.refreshCh != nil { - // A refresh is already running – wait for it to finish. + // A refresh is already running – wait for it to finish and return ITS + // outcome. Returning nil unconditionally (the previous behaviour) made + // waiters believe a failed refresh had succeeded, so they retried with + // the same expired token. ch := s.refreshCh s.refreshMu.Unlock() log.Printf("Auth: waiting for in-progress token refresh") select { case <-ch: - return nil + s.refreshMu.Lock() + err := s.refreshErr + s.refreshMu.Unlock() + return err case <-ctx.Done(): return ctx.Err() } @@ -102,14 +112,16 @@ func (s *Service) RefreshToken(ctx context.Context, cfg *config.Config) error { s.refreshCh = make(chan struct{}) s.refreshMu.Unlock() - defer func() { - s.refreshMu.Lock() - close(s.refreshCh) - s.refreshCh = nil - s.refreshMu.Unlock() - }() + err := s.doRefresh(ctx, cfg) + + // Publish the result, then release waiters. + s.refreshMu.Lock() + s.refreshErr = err + close(s.refreshCh) + s.refreshCh = nil + s.refreshMu.Unlock() - return s.doRefresh(ctx, cfg) + return err } // doRefresh performs the actual network token exchange. Must only be called by @@ -152,15 +164,36 @@ func (s *Service) doRefresh(ctx context.Context, cfg *config.Config) error { log.Printf("Auth: warning – failed to update session after refresh: %v", err) } - // Persist to disk atomically so the tokens survive a process restart. - if err := cfg.SaveAtomic(); err != nil { - log.Printf("Auth: warning – failed to persist refreshed tokens: %v", err) - } else { - log.Printf("Auth: token refresh successful (token length: %d)", len(session.AccessToken)) + // Persist to disk so the rotated tokens survive a restart. Supabase + // rotates the refresh token on every refresh, so if we fail to persist + // the new pair and the process later restarts, disk holds a refresh token + // the backend has already revoked — leaving the agent permanently unable + // to authenticate. Treat a persistent save failure as a refresh failure + // (after retries) rather than silently swallowing it. + if err := saveTokensWithRetry(cfg); err != nil { + return fmt.Errorf("token refreshed but failed to persist rotated tokens: %w", err) } + log.Printf("Auth: token refresh successful (token length: %d)", len(session.AccessToken)) return nil } return fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr) } + +// saveTokensWithRetry persists the config with a few bounded retries to ride out +// a transient disk/IO error. Returns the last error if all attempts fail. +func saveTokensWithRetry(cfg *config.Config) error { + const saveAttempts = 3 + var err error + for attempt := 0; attempt < saveAttempts; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 200 * time.Millisecond) + } + if err = cfg.SaveAtomic(); err == nil { + return nil + } + log.Printf("Auth: persist refreshed tokens attempt %d/%d failed: %v", attempt+1, saveAttempts, err) + } + return err +} diff --git a/internal/service/auth/jwt.go b/internal/service/auth/jwt.go new file mode 100644 index 0000000..71c467d --- /dev/null +++ b/internal/service/auth/jwt.go @@ -0,0 +1,58 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// TokenExpiry parses the `exp` (expiration) claim from a JWT and returns it as a +// time.Time. The signature is NOT verified — the Supabase backend is the +// authority on validity; the agent only needs the expiry to schedule a +// proactive refresh before the token lapses. +func TokenExpiry(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("not a JWT: expected 3 segments, got %d", len(parts)) + } + + // JWTs use base64url without padding, but tolerate padded variants too. + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { + return time.Time{}, fmt.Errorf("decode payload: %w", err) + } + } + + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return time.Time{}, fmt.Errorf("parse claims: %w", err) + } + if claims.Exp == 0 { + return time.Time{}, fmt.Errorf("token has no exp claim") + } + + return time.Unix(claims.Exp, 0), nil +} + +// ShouldRefresh reports whether the access token should be refreshed now. It +// returns true when the token is empty, cannot be parsed, is already expired, or +// will expire within skew. This replaces the previous time-since-last-check +// heuristic, which never fired and let the token silently expire ~1h after +// startup, halting all reporting. +func ShouldRefresh(token string, skew time.Duration) bool { + if token == "" { + return true + } + exp, err := TokenExpiry(token) + if err != nil { + // Unparseable token: refresh to be safe rather than risk operating with + // an expired credential. + return true + } + return time.Now().Add(skew).After(exp) +} diff --git a/internal/service/auth/jwt_test.go b/internal/service/auth/jwt_test.go new file mode 100644 index 0000000..7e491cd --- /dev/null +++ b/internal/service/auth/jwt_test.go @@ -0,0 +1,56 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "testing" + "time" +) + +// makeJWT builds a signature-less-but-well-formed JWT with the given exp claim. +func makeJWT(exp int64) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, exp))) + return header + "." + payload + ".sig" +} + +func TestTokenExpiry(t *testing.T) { + want := time.Now().Add(time.Hour).Unix() + got, err := TokenExpiry(makeJWT(want)) + if err != nil { + t.Fatalf("TokenExpiry: %v", err) + } + if got.Unix() != want { + t.Errorf("TokenExpiry = %d, want %d", got.Unix(), want) + } + + for _, bad := range []string{"", "notajwt", "a.b", "a.b.c.d", "x.@@@.z"} { + if _, err := TokenExpiry(bad); err == nil { + t.Errorf("TokenExpiry(%q) = nil error, want error", bad) + } + } +} + +func TestShouldRefresh(t *testing.T) { + skew := 5 * time.Minute + + // Fresh token (expires in 1h) -> no refresh. + if ShouldRefresh(makeJWT(time.Now().Add(time.Hour).Unix()), skew) { + t.Error("fresh token should not need refresh") + } + // Within skew of expiry -> refresh. + if !ShouldRefresh(makeJWT(time.Now().Add(2*time.Minute).Unix()), skew) { + t.Error("token within skew should need refresh") + } + // Already expired -> refresh. + if !ShouldRefresh(makeJWT(time.Now().Add(-time.Minute).Unix()), skew) { + t.Error("expired token should need refresh") + } + // Empty / unparseable -> refresh. + if !ShouldRefresh("", skew) { + t.Error("empty token should need refresh") + } + if !ShouldRefresh("garbage", skew) { + t.Error("unparseable token should need refresh") + } +} diff --git a/internal/service/rpcutil/rpcutil.go b/internal/service/rpcutil/rpcutil.go new file mode 100644 index 0000000..b1bb9d6 --- /dev/null +++ b/internal/service/rpcutil/rpcutil.go @@ -0,0 +1,41 @@ +// Package rpcutil provides shared helpers for bounding Supabase RPC calls. +package rpcutil + +import ( + "context" + "fmt" + "time" +) + +// CallWithTimeout runs fn (a blocking RPC) and returns its result, or an error +// if ctx is cancelled or timeout elapses first. +// +// The postgrest-go client has no HTTP timeout and its Rpc method ignores +// context, so a hung connection (NAT timeout, half-open TCP) would otherwise +// block the caller forever. In the scheduler that leaves the task's Running +// flag set, so every subsequent tick is skipped and reporting silently stops +// until the process restarts. Bounding the caller here guarantees the task +// returns and runs again on the next tick. +func CallWithTimeout(ctx context.Context, timeout time.Duration, fn func() (string, error)) (string, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + type result struct { + s string + err error + } + // Buffered so the goroutine never blocks on send if we have already + // returned via the timeout branch. + ch := make(chan result, 1) + go func() { + s, err := fn() + ch <- result{s: s, err: err} + }() + + select { + case r := <-ch: + return r.s, r.err + case <-ctx.Done(): + return "", fmt.Errorf("rpc timed out after %s: %w", timeout, ctx.Err()) + } +} diff --git a/internal/service/rpcutil/rpcutil_test.go b/internal/service/rpcutil/rpcutil_test.go new file mode 100644 index 0000000..22bfd55 --- /dev/null +++ b/internal/service/rpcutil/rpcutil_test.go @@ -0,0 +1,41 @@ +package rpcutil + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestCallWithTimeout_Fast(t *testing.T) { + got, err := CallWithTimeout(context.Background(), time.Second, func() (string, error) { + return "ok", nil + }) + if err != nil || got != "ok" { + t.Fatalf("got (%q, %v), want (\"ok\", nil)", got, err) + } +} + +func TestCallWithTimeout_PropagatesError(t *testing.T) { + sentinel := errors.New("boom") + _, err := CallWithTimeout(context.Background(), time.Second, func() (string, error) { + return "", sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want wrapping %v", err, sentinel) + } +} + +func TestCallWithTimeout_TimesOut(t *testing.T) { + start := time.Now() + _, err := CallWithTimeout(context.Background(), 50*time.Millisecond, func() (string, error) { + time.Sleep(2 * time.Second) // simulates a hung RPC + return "late", nil + }) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("CallWithTimeout blocked %s, should have returned at ~50ms", elapsed) + } +} diff --git a/internal/service/software/sync.go b/internal/service/software/sync.go index 0c6ea7e..cd6d37d 100644 --- a/internal/service/software/sync.go +++ b/internal/service/software/sync.go @@ -13,11 +13,16 @@ import ( "sentinelgo/internal/config" "sentinelgo/internal/sanitize" + "sentinelgo/internal/service/rpcutil" "sentinelgo/internal/store" postgrest "github.com/supabase-community/postgrest-go" ) +// rpcTimeout bounds a single Supabase RPC so a hung connection cannot wedge the +// scheduled software-sync task. +const rpcTimeout = 60 * time.Second + // SendSoftwareData sends software data to the Edge Function, falling back to // the PostgREST RPC if the edge function is not deployed (404). func (s *SoftwareService) SendSoftwareData(ctx context.Context, agentID string, software []SoftwareInfo) error { @@ -142,7 +147,7 @@ func (s *SoftwareService) sendByRestAPI(ctx context.Context, _ string, software var accessToken string var anonKey string if cfg != nil { - accessToken = cfg.AccessToken + accessToken = cfg.GetAccessToken() anonKey = cfg.SupabaseKey } if accessToken == "" { @@ -160,11 +165,16 @@ func (s *SoftwareService) sendByRestAPI(ctx context.Context, _ string, software }, ) - rawResult := client.Rpc("agent_upsert_software", "", map[string]interface{}{ - "payload": map[string]interface{}{ - "software": items, - }, + rawResult, err := rpcutil.CallWithTimeout(ctx, rpcTimeout, func() (string, error) { + return client.Rpc("agent_upsert_software", "", map[string]interface{}{ + "payload": map[string]interface{}{ + "software": items, + }, + }), client.ClientError }) + if err != nil { + return fmt.Errorf("call agent_upsert_software RPC: %w", err) + } if rawResult == "" { return fmt.Errorf("call agent_upsert_software RPC: empty response") } diff --git a/internal/service/task/executor.go b/internal/service/task/executor.go index 619ac6d..f77d70f 100644 --- a/internal/service/task/executor.go +++ b/internal/service/task/executor.go @@ -171,7 +171,7 @@ func (s *TaskExecutorService) downloadScript(ctx context.Context, remotePath, lo } req.Header.Set("apikey", s.cfg.SupabaseKey) - req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken) + req.Header.Set("Authorization", "Bearer "+s.cfg.GetAccessToken()) resp, err := s.client.Do(req) if err != nil { diff --git a/internal/service/task/polling.go b/internal/service/task/polling.go index 4e54c22..39d825f 100644 --- a/internal/service/task/polling.go +++ b/internal/service/task/polling.go @@ -41,7 +41,7 @@ type TaskPollingService struct { // NewTaskPollingService creates a new task polling service. func NewTaskPollingService(cfg *config.Config, dbPath string) (*TaskPollingService, error) { - client := taskstore.NewClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.AccessToken) + client := taskstore.NewClient(cfg.SupabaseURL, cfg.SupabaseKey, cfg.GetAccessToken()) taskStore, err := store.NewTaskStore(dbPath) if err != nil { diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 0da638f..9bb5dad 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -8,6 +8,7 @@ import ( "log" "net" "net/http" + "os" "runtime" "strings" "sync" @@ -30,16 +31,35 @@ type Asset struct { URL string `json:"browser_download_url"` } -// ProcessInfo contains information about a running SentinelGo process -type ProcessInfo struct { - PID int - Version string - CmdLine string -} - var updateMutex sync.Mutex +// windowsServiceName is the SCM service name registered at install time +// (see cmd/sentinelgo/main.go). The Windows update path restarts via SCM +// because a running .exe cannot be replaced in place. +const windowsServiceName = "SentinelGo" + +// HTTP clients with finite timeouts. http.DefaultClient has no timeout, so a +// hung connection during a release check or download would block the update +// path forever while holding updateMutex. +var ( + apiClient = httpx.NewClient(30 * time.Second) + downloadClient = httpx.NewClient(10 * time.Minute) +) + // CheckAndApply checks for a newer release and applies the update if available. +// +// Order of operations is deliberate (see the audit fixes for C3/C4 and M2): +// 1. Compare against the COMPILED-IN running version (config.Version), not the +// persisted cfg.CurrentVersion. The running binary's version is the source +// of truth, so a failed swap can never leave the agent reporting a version +// it isn't actually running. +// 2. Only update on a strictly-newer semantic version (blocks downgrades). +// 3. Require a SHA256 checksum and a trusted HTTPS GitHub URL. +// 4. Download and verify BEFORE replacing anything; abort cleanly on any +// failure so monitoring capacity is never lost to a failed update. +// 5. Replace + restart via the OS service manager. The version is NOT +// persisted here — the restarted new binary asserts its own version on +// startup (config.Load), so a failed update is retried on the next check. func CheckAndApply(ctx context.Context, cfg *config.Config, token string) error { updateMutex.Lock() defer updateMutex.Unlock() @@ -49,13 +69,17 @@ func CheckAndApply(ctx context.Context, cfg *config.Config, token string) error return fmt.Errorf("fetch latest release: %w", err) } - if latest.TagName == cfg.CurrentVersion { - fmt.Printf("Already up to date: %s\n", latest.TagName) - // Still update config to ensure it reflects current version - cfg.CurrentVersion = latest.TagName - if err := cfg.SaveAtomic(); err != nil { - return fmt.Errorf("save config: %w", err) - } + running := config.Version + newer, err := isNewerVersion(latest.TagName, running) + if err != nil { + // Cannot compare versions (e.g. a "dev" build). Skip rather than risk + // applying an unverifiable or wrong-direction change. + fmt.Printf("Skipping update: cannot compare versions (%v)\n", err) + return nil + } + if !newer { + fmt.Printf("Already up to date (running %s, latest %s)\n", + sanitize.ForLog(running), sanitize.ForLog(latest.TagName)) return nil } @@ -64,94 +88,59 @@ func CheckAndApply(ctx context.Context, cfg *config.Config, token string) error return fmt.Errorf("select asset: %w", err) } - // NOTE: expectedChecksum may be empty if the release does not include a SHA256SUMS - // file or embed checksums in asset names. In that case, verification is skipped with - // a warning. Future work: require a checksum by publishing SHA256SUMS with each release. - - sanitizedCurrentVersion := sanitize.ForLog(cfg.CurrentVersion) - sanitizedTagName := sanitize.ForLog(latest.TagName) - fmt.Printf("Found update: %s -> %s\n", sanitizedCurrentVersion, sanitizedTagName) - - backupPath, err := createBackup() - if err != nil { - fmt.Printf("Warning: Failed to create backup: %v\n", err) - } else { - fmt.Printf("Created backup: %s\n", backupPath) + // M2: only download from a trusted HTTPS GitHub host. + if err := validateGitHubURL(assetURL); err != nil { + return fmt.Errorf("refusing to download release asset: %w", err) } - fmt.Println("Stopping old SentinelGo processes before update...") - if err := stopOldProcesses(); err != nil { - fmt.Printf("Warning: Failed to stop some old processes: %v\n", err) + // M2: a checksum is mandatory. This is a corruption guard, not authenticity + // (cryptographic signature verification is a deferred follow-up). Fail closed + // rather than installing an unverifiable binary. + if expectedChecksum == "" { + return fmt.Errorf("refusing to update to %s: no SHA256 checksum published with the release", + sanitize.ForLog(latest.TagName)) } - fmt.Println("Waiting for old processes to fully terminate...") - time.Sleep(5 * time.Second) + fmt.Printf("Found update: %s -> %s\n", sanitize.ForLog(running), sanitize.ForLog(latest.TagName)) - processes, _ := findOldProcesses() - if len(processes) > 0 { - fmt.Printf("Warning: %d old process(es) still running, force killing...\n", len(processes)) - for _, proc := range processes { - fmt.Printf(" PID: %d, Version: %s\n", proc.PID, proc.Version) - } - forceKillProcesses(processes) - time.Sleep(2 * time.Second) - } else { - fmt.Println("All old processes stopped successfully") + // A backup is mandatory so a failed in-place replace can always roll back. + backupPath, err := createBackup() + if err != nil { + return fmt.Errorf("refusing to update without a backup: %w", err) } + fmt.Printf("Created backup: %s\n", backupPath) + // Download and verify BEFORE touching the running install. If anything fails + // here, the running agent is untouched. newPath, actualChecksum, err := downloadAndVerify(ctx, assetURL, expectedChecksum, latest.TagName) if err != nil { - if backupPath != "" { - fmt.Printf("Update failed, attempting rollback from backup: %s\n", backupPath) - if rollbackErr := rollbackFromBackup(backupPath); rollbackErr != nil { - return fmt.Errorf("update failed and rollback failed: %v, rollback error: %w", err, rollbackErr) - } - fmt.Println("Successfully rolled back to previous version") - } + _ = removeFile(backupPath) return fmt.Errorf("download and verify failed: %w", err) } - - if expectedChecksum != "" && actualChecksum != expectedChecksum { - if backupPath != "" { - fmt.Printf("Checksum mismatch, attempting rollback from backup: %s\n", backupPath) - if rollbackErr := rollbackFromBackup(backupPath); rollbackErr != nil { - return fmt.Errorf("checksum mismatch and rollback failed; rollback error: %w", rollbackErr) - } - fmt.Println("Successfully rolled back to previous version due to checksum mismatch") - } + if actualChecksum != expectedChecksum { + _ = os.Remove(newPath) + _ = removeFile(backupPath) return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum) } - if expectedChecksum == "" { - fmt.Printf("Warning: No checksum provided in release, skipping verification. Downloaded checksum: %s\n", actualChecksum) - } - + // Replace the binary. On Unix this is an atomic in-place rename now; on + // Windows the running .exe cannot be replaced, so the swap is deferred to a + // post-exit script in restart(). if runtime.GOOS != "windows" { if err := atomicReplace(newPath); err != nil { - if backupPath != "" { - fmt.Printf("Atomic replacement failed, attempting rollback from backup: %s\n", backupPath) - if rollbackErr := rollbackFromBackup(backupPath); rollbackErr != nil { - return fmt.Errorf("atomic replacement failed and rollback failed: %w, rollback error: %w", err, rollbackErr) - } - fmt.Println("Successfully rolled back to previous version") + if rbErr := rollbackFromBackup(backupPath); rbErr != nil { + return fmt.Errorf("atomic replace failed (%v) and rollback failed: %w", err, rbErr) } - return fmt.Errorf("atomic replacement failed: %w", err) + _ = removeFile(backupPath) + return fmt.Errorf("atomic replace failed, rolled back to previous version: %w", err) } - } else { - fmt.Println("Windows update will replace binary after restart") } - fmt.Printf("Successfully updated to version %s\n", latest.TagName) - - cfg.CurrentVersion = latest.TagName - if err := cfg.SaveAtomic(); err != nil { - return fmt.Errorf("save config: %w", err) - } - - if backupPath != "" { - _ = removeFile(backupPath) - } + fmt.Printf("Update verified and staged: %s -> %s\n", running, latest.TagName) + _ = removeFile(backupPath) + // Hand off to the service manager. Does not persist CurrentVersion — see the + // function doc; the restarted binary reconciles its own version on startup. return restart(newPath) } @@ -195,7 +184,7 @@ func fetchLatestRelease(ctx context.Context, cfg *config.Config, token string) ( req.Header.Set("Authorization", "Bearer "+token) } - resp, err := http.DefaultClient.Do(req) + resp, err := apiClient.Do(req) if err != nil { return nil, err } @@ -270,7 +259,7 @@ func downloadAndParseChecksumFile(url string) string { return "" } - resp, err := http.DefaultClient.Do(req) + resp, err := apiClient.Do(req) if err != nil { return "" } diff --git a/internal/updater/downloader.go b/internal/updater/downloader.go index 103dced..6f8a28b 100644 --- a/internal/updater/downloader.go +++ b/internal/updater/downloader.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "os" + + "sentinelgo/internal/winsec" ) // downloadAndVerify downloads the binary from url, writes it to a temp file, @@ -19,7 +21,7 @@ func downloadAndVerify(ctx context.Context, url, expectedChecksum, version strin return "", "", err } - resp, err := http.DefaultClient.Do(req) + resp, err := downloadClient.Do(req) if err != nil { return "", "", err } @@ -50,5 +52,13 @@ func downloadAndVerify(ctx context.Context, url, expectedChecksum, version strin } actualChecksum := hex.EncodeToString(hash.Sum(nil)) + + // Lock the staged binary down so a standard user cannot swap it for a + // malicious one between download and the privileged replace/restart. + if err := winsec.SecurePath(newPath); err != nil { + // Non-fatal: log and proceed (checksum is still verified by the caller). + fmt.Printf("Warning: failed to secure staged binary ACL: %v\n", err) + } + return newPath, actualChecksum, nil } diff --git a/internal/updater/installer.go b/internal/updater/installer.go index 37e0040..f7f87a4 100644 --- a/internal/updater/installer.go +++ b/internal/updater/installer.go @@ -8,9 +8,8 @@ import ( "os/exec" "path/filepath" "runtime" - "strconv" - "strings" - "time" + + "sentinelgo/internal/winsec" ) // atomicReplace replaces the running binary with newPath using an atomic rename. @@ -133,175 +132,85 @@ func rollbackFromBackup(backupPath string) error { return nil } -// restart replaces the binary and starts the new version. +// restart hands control to the OS service manager so the new binary runs. +// +// On Linux/macOS the binary has ALREADY been replaced in place by atomicReplace +// before this is called, so we simply exit and let the service manager relaunch +// the (now-new) binary: +// - macOS launchd has KeepAlive=true: it relaunches on any exit. +// - Linux systemd has Restart=on-failure: it relaunches on a non-zero exit. +// +// On Windows a running .exe cannot be replaced in place, so the swap is deferred +// to a small script that waits for this process to exit, moves the new binary +// into place, and starts the service again via the SCM. +// +// The version is intentionally NOT persisted before this point: if the swap or +// relaunch fails, the old binary keeps running and its compiled-in version +// (config.Version) ensures the update is retried on the next check, rather than +// the agent silently reporting a version it isn't running. func restart(newPath string) error { selfPath, err := os.Executable() if err != nil { return err } - if runtime.GOOS == "darwin" { - return restartDarwin(newPath, selfPath) - } - - if runtime.GOOS != "windows" { - if err := os.Rename(newPath, selfPath); err != nil { - return err - } - time.Sleep(2 * time.Second) - // #nosec G204 - selfPath is a controlled path for self-update - cmd := exec.Command(selfPath) - if err := cmd.Start(); err != nil { - return err - } - os.Exit(0) - return nil - } - - // Windows: use batch script to replace after exit - bat := selfPath + ".bat" - script := fmt.Sprintf(`@echo off - -timeout /t 2 /nobreak >nul -move /Y "%s" "%s" -"%s" -del "%s"`, newPath, selfPath, selfPath, bat) - // #nosec G306 - Windows batch scripts need to be readable by the system - if err := os.WriteFile(bat, []byte(script), 0644); err != nil { - return err - } - // #nosec G204 - bat is a controlled path for self-update - cmd := exec.Command(bat) - if err := cmd.Start(); err != nil { - return err - } - os.Exit(0) - return nil -} - -func restartDarwin(newPath, selfPath string) error { - if err := stopLaunchdService(); err != nil { - fmt.Printf("Warning: Failed to stop launchd service: %v\n", err) - } - - time.Sleep(3 * time.Second) - - if err := os.Rename(newPath, selfPath); err != nil { - return fmt.Errorf("failed to replace binary: %w", err) - } - - if _, err := os.Stat(selfPath); os.IsNotExist(err) { - return fmt.Errorf("new binary not found after replacement: %w", err) + if runtime.GOOS == "windows" { + return restartWindows(newPath, selfPath) } - fmt.Printf("Successfully updated to version %s\n", extractVersionFromPath(newPath)) - - time.Sleep(2 * time.Second) - - if err := startLaunchdService(); err != nil { - fmt.Printf("Warning: Failed to start launchd service: %v\n", err) - fmt.Println("Falling back to direct execution...") - // #nosec G204 - selfPath is a controlled path for self-update - cmd := exec.Command(selfPath, "-run") - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start fallback execution: %w", err) - } - fmt.Println("Started SentinelGo in direct execution mode") + if runtime.GOOS == "darwin" { + log.Println("Updater: update applied; exiting for launchd (KeepAlive) to relaunch the new binary") os.Exit(0) - } - - time.Sleep(3 * time.Second) - fmt.Println("Verifying only new version is running...") - - finalCheck, _ := findOldProcesses() - if len(finalCheck) > 0 { - fmt.Printf("Warning: Found %d old process(es) still running after update:\n", len(finalCheck)) - for _, proc := range finalCheck { - fmt.Printf(" PID: %d, Version: %s\n", proc.PID, proc.Version) - } - fmt.Println("Force stopping remaining old processes...") - forceKillProcesses(finalCheck) - time.Sleep(1 * time.Second) - } else { - fmt.Println("Success: Only new version is running") - } - - return nil -} - -func stopLaunchdService() error { - if runtime.GOOS != "darwin" { return nil } - cmd := exec.Command("launchctl", "list", "com.sentinelgo.agent") - if err := cmd.Run(); err != nil { - return nil // not running - } - - fmt.Println("Stopping launchd service...") - cmd = exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/com.sentinelgo.agent.plist") - if err := cmd.Run(); err != nil { - fmt.Printf("Warning: Failed to unload launchd service: %v\n", err) - } - + // Linux and other systemd-managed platforms: a non-zero exit triggers + // Restart=on-failure, relaunching the replaced binary. + log.Println("Updater: update applied; exiting for systemd (Restart=on-failure) to relaunch the new binary") + os.Exit(1) return nil } -func startLaunchdService() error { - if runtime.GOOS != "darwin" { - return nil - } - - fmt.Println("Starting launchd service...") +// restartWindows defers the binary swap to a script because the running .exe is +// locked. The script waits for this process to exit, replaces the binary with a +// retry loop (the file unlocks only once we exit), then restarts the service via +// the SCM. `timeout` is avoided: it fails in non-interactive Session 0 with +// "Input redirection is not supported"; `ping` provides the delay instead. +func restartWindows(newPath, selfPath string) error { + dir := filepath.Dir(selfPath) + bat := filepath.Join(dir, "sentinelgo_update.bat") - plistPath := "/Library/LaunchDaemons/com.sentinelgo.agent.plist" - if _, err := os.Stat(plistPath); os.IsNotExist(err) { - fmt.Printf("Launchd plist not found at %s\n", plistPath) - return fmt.Errorf("launchd plist file not found - service may not be installed") - } + script := fmt.Sprintf(`@echo off +ping -n 3 127.0.0.1 >nul +:retry +move /Y "%s" "%s" >nul 2>&1 +if errorlevel 1 ( + ping -n 2 127.0.0.1 >nul + goto retry +) +sc start "%s" >nul 2>&1 +del "%s" >nul 2>&1 +`, newPath, selfPath, windowsServiceName, bat) - cmd := exec.Command("launchctl", "load", "-w", plistPath) - if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("Failed to load launchd service: %v\nOutput: %s\n", err, string(output)) - return fmt.Errorf("failed to load launchd service: %w", err) + // #nosec G306 - the update script must be executable/readable by the system + if err := os.WriteFile(bat, []byte(script), 0644); err != nil { + return fmt.Errorf("write update script: %w", err) } - time.Sleep(500 * time.Millisecond) - - cmd = exec.Command("launchctl", "start", "com.sentinelgo.agent") - if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("Failed to start launchd service: %v\nOutput: %s\n", err, string(output)) - return fmt.Errorf("failed to start launchd service: %w", err) + // Lock the script down to SYSTEM/Administrators/owner so a standard user + // cannot tamper with it during the window before it runs (it executes with + // the service's privileges). + if err := winsec.SecurePath(bat); err != nil { + log.Printf("Updater: warning – failed to secure update script ACL: %v", err) } - time.Sleep(1 * time.Second) - cmd = exec.Command("launchctl", "list", "com.sentinelgo.agent") - if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("Warning: Could not verify launchd service status: %v\n", err) - } else if strings.Contains(string(output), "com.sentinelgo.agent") { - fmt.Println("Launchd service started successfully") - } else { - fmt.Printf("Warning: Launchd service may not be running properly. Output: %s\n", string(output)) + // #nosec G204 - bat is a controlled path generated above + cmd := exec.Command("cmd", "/c", "start", "/b", "", bat) + if err := cmd.Start(); err != nil { + return fmt.Errorf("launch update script: %w", err) } + log.Println("Updater: update staged; exiting so the SCM can restart the service with the new binary") + os.Exit(0) return nil } - -// forceKillProcesses sends SIGKILL/taskkill to all listed processes. -func forceKillProcesses(processes []ProcessInfo) { - for _, proc := range processes { - var cmd *exec.Cmd - switch runtime.GOOS { - case "windows": - // #nosec G204 - taskkill is a system command with controlled arguments - cmd = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(proc.PID)) - case "linux", "darwin": - // #nosec G204 - kill is a system command with controlled arguments - cmd = exec.Command("kill", "-KILL", strconv.Itoa(proc.PID)) - } - if err := cmd.Run(); err != nil { - fmt.Printf("Warning: failed to kill process %d: %v\n", proc.PID, err) - } - } -} diff --git a/internal/updater/process.go b/internal/updater/process.go deleted file mode 100644 index a543869..0000000 --- a/internal/updater/process.go +++ /dev/null @@ -1,234 +0,0 @@ -package updater - -import ( - "fmt" - "os" - "os/exec" - "runtime" - "strconv" - "strings" - "time" - - "sentinelgo/internal/config" -) - -// findOldProcesses finds all running SentinelGo processes except the current one. -func findOldProcesses() ([]ProcessInfo, error) { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - cmd = exec.Command("tasklist", "/fi", "imagename eq sentinelgo.exe", "/fo", "csv", "/v") - case "linux", "darwin": - cmd = exec.Command("ps", "aux") - default: - return nil, fmt.Errorf("unsupported OS: %s", runtime.GOOS) - } - - output, err := cmd.Output() - if err != nil { - return nil, err - } - - return parseProcessOutput(string(output)), nil -} - -func parseProcessOutput(output string) []ProcessInfo { - var processes []ProcessInfo - currentPID := os.Getpid() - currentVersion := getCurrentVersion() - - for _, line := range strings.Split(output, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - - var info ProcessInfo - - switch runtime.GOOS { - case "windows": - if !strings.Contains(line, "sentinelgo.exe") { - continue - } - fields := strings.Split(line, ",") - if len(fields) < 5 { - continue - } - pid, _ := strconv.Atoi(strings.Trim(fields[1], `"`)) - if pid == currentPID { - continue - } - info.PID = pid - if len(fields) > 8 { - info.CmdLine = strings.Trim(fields[8], `"`) - } - info.Version = getProcessVersion(info.CmdLine, pid) - // Only kill if version is positively known to be different. - // Skipping "unknown" prevents killing newly started processes. - if info.Version != "unknown" && info.Version != currentVersion { - processes = append(processes, info) - } - - case "linux", "darwin": - if !strings.Contains(line, "sentinelgo") || - strings.Contains(line, "grep") || - strings.Contains(line, "systemctl") || - strings.Contains(line, "journalctl") || - strings.Contains(line, "editor") { - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - pid, _ := strconv.Atoi(fields[1]) - if pid == currentPID { - continue - } - info.PID = pid - if len(fields) > 10 { - info.CmdLine = strings.Join(fields[10:], " ") - } - info.Version = getProcessVersion(info.CmdLine, pid) - if info.Version != "unknown" && info.Version != currentVersion { - processes = append(processes, info) - } - } - } - - return processes -} - -func getProcessVersion(cmdLine string, pid int) string { - if version := extractVersionFromCmd(cmdLine); version != "unknown" { - return version - } - if version := getBinaryVersion(cmdLine); version != "unknown" { - return version - } - if version := extractVersionFromPath(cmdLine); version != "unknown" { - return version - } - return "unknown" -} - -func extractVersionFromCmd(cmdLine string) string { - if strings.Contains(cmdLine, "-version=") { - parts := strings.Split(cmdLine, "-version=") - if len(parts) > 1 { - return strings.Trim(strings.Split(parts[1], " ")[0], `"`) - } - } - - parts := strings.Fields(cmdLine) - for i, part := range parts { - if (part == "-version" || part == "--version") && i+1 < len(parts) { - return strings.Trim(parts[i+1], `"`) - } - } - - return "unknown" -} - -func getBinaryVersion(cmdLine string) string { - parts := strings.Fields(cmdLine) - if len(parts) == 0 { - return "unknown" - } - - binaryPath := parts[0] - if !strings.Contains(binaryPath, "/") && runtime.GOOS != "windows" { - if path, err := exec.LookPath(binaryPath); err == nil { - binaryPath = path - } - } - - if binaryPath == "" { - return "unknown" - } - - // #nosec G204 - binaryPath is a controlled path from self-update process - output, err := exec.Command(binaryPath, "-version").Output() - if err != nil { - return "unknown" - } - - for _, line := range strings.Split(string(output), "\n") { - if !strings.Contains(line, "version") { - continue - } - fields := strings.Fields(line) - for i, part := range fields { - if strings.Contains(part, "version") && i+1 < len(fields) { - return strings.Trim(fields[i+1], ",") - } - } - } - - return "unknown" -} - -func getCurrentVersion() string { - cfg, err := config.Load("") - if err == nil && cfg.CurrentVersion != "" { - return cfg.CurrentVersion - } - return config.Version -} - -func extractVersionFromPath(path string) string { - for _, part := range strings.Split(path, "-") { - if strings.HasPrefix(part, "v") { - return strings.Trim(part, `"`) - } - } - return "unknown" -} - -// stopOldProcesses sends SIGTERM to old processes, waits, then force-kills any remaining. -func stopOldProcesses() error { - processes, err := findOldProcesses() - if err != nil { - return err - } - - if len(processes) == 0 { - fmt.Println("No old SentinelGo processes found") - return nil - } - - fmt.Printf("Found %d old SentinelGo process(es) to stop:\n", len(processes)) - for _, proc := range processes { - fmt.Printf(" PID: %d, Version: %s\n", proc.PID, proc.Version) - } - - fmt.Println("Stopping old processes...") - for _, proc := range processes { - var cmd *exec.Cmd - switch runtime.GOOS { - case "windows": - // #nosec G204 - taskkill is a system command with controlled arguments - cmd = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(proc.PID)) - case "linux", "darwin": - // #nosec G204 - kill is a system command with controlled arguments - cmd = exec.Command("kill", "-TERM", strconv.Itoa(proc.PID)) - } - - if err := cmd.Run(); err != nil { - fmt.Printf("Failed to stop PID %d: %v\n", proc.PID, err) - } else { - fmt.Printf("Stopped PID %d\n", proc.PID) - } - } - - time.Sleep(3 * time.Second) - - remaining, _ := findOldProcesses() - if len(remaining) > 0 { - fmt.Printf("Force killing %d remaining process(es)...\n", len(remaining)) - forceKillProcesses(remaining) - time.Sleep(2 * time.Second) - } - - return nil -} diff --git a/internal/updater/version.go b/internal/updater/version.go new file mode 100644 index 0000000..55186f3 --- /dev/null +++ b/internal/updater/version.go @@ -0,0 +1,121 @@ +package updater + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// semver is a parsed major.minor.patch version. Pre-release and build metadata +// (anything after '-' or '+') are ignored for comparison purposes. +type semver struct { + major, minor, patch int +} + +// parseSemver parses a version string of the form "vX.Y.Z" or "X.Y.Z". +// A leading 'v' is optional. Missing minor/patch components default to 0. +func parseSemver(s string) (semver, error) { + v := strings.TrimSpace(s) + v = strings.TrimPrefix(v, "v") + v = strings.TrimPrefix(v, "V") + + // Strip pre-release / build metadata. + if i := strings.IndexAny(v, "-+"); i != -1 { + v = v[:i] + } + + if v == "" { + return semver{}, fmt.Errorf("empty version") + } + + parts := strings.Split(v, ".") + if len(parts) > 3 { + return semver{}, fmt.Errorf("invalid version %q: too many components", s) + } + + nums := make([]int, 3) + for i := 0; i < len(parts); i++ { + n, err := strconv.Atoi(parts[i]) + if err != nil { + return semver{}, fmt.Errorf("invalid version %q: %w", s, err) + } + if n < 0 { + return semver{}, fmt.Errorf("invalid version %q: negative component", s) + } + nums[i] = n + } + + return semver{major: nums[0], minor: nums[1], patch: nums[2]}, nil +} + +// compare returns -1 if a < b, 0 if equal, +1 if a > b. +func (a semver) compare(b semver) int { + switch { + case a.major != b.major: + return cmpInt(a.major, b.major) + case a.minor != b.minor: + return cmpInt(a.minor, b.minor) + default: + return cmpInt(a.patch, b.patch) + } +} + +func cmpInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +// isNewerVersion reports whether candidate is a strictly newer release than +// current. Both must be valid semantic versions; an error is returned when +// either cannot be parsed (e.g. a "dev" build), in which case the caller should +// skip the update rather than apply an unverifiable change. This blocks +// downgrade attacks: an older or equal tag never triggers an update. +func isNewerVersion(candidate, current string) (bool, error) { + c, err := parseSemver(candidate) + if err != nil { + return false, fmt.Errorf("candidate version: %w", err) + } + cur, err := parseSemver(current) + if err != nil { + return false, fmt.Errorf("current version: %w", err) + } + return c.compare(cur) > 0, nil +} + +// allowedDownloadHosts is the set of hosts the updater will download release +// assets from. GitHub serves release binaries from github.com and redirects to +// *.githubusercontent.com object storage. +var allowedDownloadHosts = []string{ + "github.com", + "api.github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +} + +// validateGitHubURL ensures rawURL is an HTTPS URL pointing at a trusted GitHub +// host before the updater downloads from it. This is a defense-in-depth check; +// it is NOT a substitute for the (deferred) cryptographic signature verification +// that establishes binary authenticity. +func validateGitHubURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse url: %w", err) + } + if u.Scheme != "https" { + return fmt.Errorf("scheme %q is not https", u.Scheme) + } + host := u.Hostname() + for _, allowed := range allowedDownloadHosts { + if host == allowed || strings.HasSuffix(host, ".githubusercontent.com") { + return nil + } + } + return fmt.Errorf("host %q is not an allowed GitHub host", host) +} diff --git a/internal/updater/version_test.go b/internal/updater/version_test.go new file mode 100644 index 0000000..7e1dd90 --- /dev/null +++ b/internal/updater/version_test.go @@ -0,0 +1,60 @@ +package updater + +import "testing" + +func TestIsNewerVersion(t *testing.T) { + cases := []struct { + candidate, current string + want bool + wantErr bool + }{ + {"v2.1.6", "v2.1.5", true, false}, // patch bump + {"v2.2.0", "v2.1.5", true, false}, // minor bump + {"v3.0.0", "v2.9.9", true, false}, // major bump + {"v2.1.5", "v2.1.5", false, false}, // equal -> not newer + {"v2.1.4", "v2.1.5", false, false}, // older -> downgrade blocked + {"2.1.6", "2.1.5", true, false}, // no leading v + {"v2.1.6-rc1", "v2.1.5", true, false}, // pre-release suffix ignored + {"v2.2", "v2.1.9", true, false}, // missing patch defaults to 0 + {"dev", "v2.1.5", false, true}, // unparseable candidate + {"v2.1.6", "dev", false, true}, // unparseable current (dev build) + } + + for _, tc := range cases { + got, err := isNewerVersion(tc.candidate, tc.current) + if (err != nil) != tc.wantErr { + t.Errorf("isNewerVersion(%q,%q) err=%v, wantErr=%v", tc.candidate, tc.current, err, tc.wantErr) + continue + } + if err == nil && got != tc.want { + t.Errorf("isNewerVersion(%q,%q) = %v, want %v", tc.candidate, tc.current, got, tc.want) + } + } +} + +func TestValidateGitHubURL(t *testing.T) { + valid := []string{ + "https://github.com/BrainStation-23/SentinelGo/releases/download/v1/sentinelgo-linux-amd64", + "https://objects.githubusercontent.com/abc/sentinelgo-linux-amd64", + "https://release-assets.githubusercontent.com/x/y", + "https://api.github.com/repos/o/r/releases/assets/1", + } + for _, u := range valid { + if err := validateGitHubURL(u); err != nil { + t.Errorf("validateGitHubURL(%q) = %v, want nil", u, err) + } + } + + invalid := []string{ + "http://github.com/o/r/x", // not https + "https://evil.com/sentinelgo", // wrong host + "https://github.com.evil.com/x", // suffix trick + "ftp://github.com/x", // wrong scheme + "not a url at all %%%", // unparseable + } + for _, u := range invalid { + if err := validateGitHubURL(u); err == nil { + t.Errorf("validateGitHubURL(%q) = nil, want error", u) + } + } +} diff --git a/internal/winsec/winsec_other.go b/internal/winsec/winsec_other.go new file mode 100644 index 0000000..0a3d334 --- /dev/null +++ b/internal/winsec/winsec_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package winsec + +// SecurePath is a no-op on non-Windows platforms; Unix callers enforce access +// with file modes (0600/0700) instead of ACLs. +func SecurePath(_ string) error { return nil } diff --git a/internal/winsec/winsec_windows.go b/internal/winsec/winsec_windows.go new file mode 100644 index 0000000..ecdacd5 --- /dev/null +++ b/internal/winsec/winsec_windows.go @@ -0,0 +1,53 @@ +//go:build windows + +// Package winsec applies restrictive Windows ACLs to files and directories that +// hold secrets or executable update artifacts. On non-Windows platforms its +// functions are no-ops (Unix file modes are handled by the caller). +package winsec + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// SecurePath applies a protected DACL to path granting full access to SYSTEM, +// the built-in Administrators group, and the account running this process only. +// Other standard users are denied access. Used for the cleartext config file +// (secrets) and for update artifacts (the staged binary and restart script), +// which run as SYSTEM and must not be tamperable by a non-privileged user during +// the update window. +func SecurePath(path string) error { + sddl := "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + if sid, err := currentUserSID(); err == nil && sid != "" { + sddl += fmt.Sprintf("(A;OICI;FA;;;%s)", sid) + } + + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return fmt.Errorf("parse SDDL: %w", err) + } + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("extract DACL: %w", err) + } + + return windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + dacl, + nil, + ) +} + +func currentUserSID() (string, error) { + tok := windows.GetCurrentProcessToken() + user, err := tok.GetTokenUser() + if err != nil { + return "", err + } + return user.User.Sid.String(), nil +} From 1d31770dda074eee9e777cf36295900256b06d28 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 18:09:57 +0600 Subject: [PATCH 3/8] Add release signing and task execution safety Add ed25519-based release signing and verification plus safety fixes for task execution and agent info collection. - Release/CI: run scripts/sign in release workflow and upload SHA256SUMS; add `sign` target to Makefile and integrate it into `make release`. Requires SENTINELGO_SIGNING_KEY env var. - Updater: selectAssetWithChecksum now locates .sig and SHA256SUMS; downloadAndVerify verifies SHA256 and ed25519 signature (fail-closed on missing/invalid .sig); added embedded PublicKey and signature verification helpers and tests. - Tools: add scripts/sign (create .sig and SHA256SUMS) and scripts/keygen (generate keypair and outputs for CI/pubkey). - Tasks: prevent unsafe re-execution by marking tasks 'executing' before running, add ResetInterruptedTasks to mark stuck tasks as failed, wire ResetInterruptedTasks into polling startup flow, and add unit tests for store behavior. - Executor: refactor to executeTask to reduce complexity, ensure marking as executing before run and report results. - Scheduler/config: reduce agent-info update interval to 5m and add a timeout around osinfo.Collect to avoid hangs; add scheduler logging for intervals. These changes improve update authenticity (signed releases) and runtime robustness (task state safety and hung-collection protection). --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 12 +++ Makefile | 20 +++- internal/config/config.go | 4 +- internal/main_integration.go | 6 ++ internal/scheduler/scheduler.go | 26 ++++- internal/service/task/executor.go | 59 +++++++---- internal/service/task/polling.go | 16 +++ internal/store/tasks.go | 36 +++++++ internal/store/tasks_test.go | 168 ++++++++++++++++++++++++++++++ internal/updater/checker.go | 51 +++++---- internal/updater/downloader.go | 74 ++++++++++++- internal/updater/pubkey.go | 19 ++++ internal/updater/signing_test.go | 121 +++++++++++++++++++++ scripts/keygen/main.go | 52 +++++++++ scripts/sign/main.go | 82 +++++++++++++++ 16 files changed, 689 insertions(+), 59 deletions(-) create mode 100644 internal/store/tasks_test.go create mode 100644 internal/updater/pubkey.go create mode 100644 internal/updater/signing_test.go create mode 100644 scripts/keygen/main.go create mode 100644 scripts/sign/main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3982fb2..b014df9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, develop] + branches: [develop] pull_request: branches: [main, develop] workflow_call: # allows release.yml to reuse this workflow directly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ef3184..ff1617b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,17 @@ jobs: cp build/darwin/sentinelgo-darwin-amd64 dist/ cp build/darwin/sentinelgo-darwin-arm64 dist/ + - name: Sign binaries and generate SHA256SUMS + env: + SENTINELGO_SIGNING_KEY: ${{ secrets.SENTINELGO_SIGNING_KEY }} + run: | + go run ./scripts/sign \ + dist/sentinelgo-linux-amd64 \ + dist/sentinelgo-linux-arm64 \ + dist/sentinelgo-darwin-amd64 \ + dist/sentinelgo-darwin-arm64 \ + dist/sentinelgo-windows-amd64.exe + - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -136,6 +147,7 @@ jobs: generate_release_notes: ${{ steps.ai_notes.outputs.success != 'true' }} files: | artifacts/sentinelgo-* + artifacts/SHA256SUMS installation-doc/install.sh installation-doc/install.bat installation-doc/INSTALLATION.md diff --git a/Makefile b/Makefile index f1bb233..275ffef 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ endif export CGO_ENABLED=0 # Targets -.PHONY: build clean clean-all all windows linux macos release version test-version deps test coverage coverage-html pre-release quality-check format-check setup packages check-no-cgo verify-cross +.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format-check setup packages check-no-cgo verify-cross all: windows linux macos @@ -52,7 +52,9 @@ macos: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o build/darwin/sentinelgo-darwin-amd64 ./cmd/sentinelgo CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o build/darwin/sentinelgo-darwin-arm64 ./cmd/sentinelgo -# Build all platforms for release +# Build all platforms for release, then sign and generate SHA256SUMS. +# Requires SENTINELGO_SIGNING_KEY env var (base64-encoded ed25519 private key). +# In CI this is injected from GitHub Actions secrets; locally set it before running. release: pre-release clean all @echo "Release built with version $(VERSION)" @echo "Assets created in build/ directory:" @@ -67,11 +69,25 @@ release: pre-release clean all @cp build/linux/sentinelgo-linux-arm64 release/ @cp build/darwin/sentinelgo-darwin-amd64 release/ @cp build/darwin/sentinelgo-darwin-arm64 release/ + @$(MAKE) sign @echo "\nRelease packages ready in release/ directory:" @ls -la release/ @echo "\nCleaning build folder files and subdirectories..." @rm -rf build/* || true +# Sign release binaries and generate SHA256SUMS. +# Reads SENTINELGO_SIGNING_KEY from the environment (base64 ed25519 private key). +# Run `go run ./scripts/keygen` once to generate the keypair if needed. +sign: + @echo "Signing release binaries..." + @go run ./scripts/sign \ + release/sentinelgo-linux-amd64 \ + release/sentinelgo-linux-arm64 \ + release/sentinelgo-darwin-amd64 \ + release/sentinelgo-darwin-arm64 \ + release/sentinelgo-windows-amd64.exe + @echo "Signatures and SHA256SUMS written to release/" + clean: rm -rf build/ diff --git a/internal/config/config.go b/internal/config/config.go index 46a52fe..e673032 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,7 +164,7 @@ func (c *Config) GetAutoUpdateInterval() time.Duration { // GetAgentInfoUpdateInterval returns how often to push inventory to Supabase. func (c *Config) GetAgentInfoUpdateInterval() time.Duration { if time.Duration(c.AgentInfoUpdateInterval) == 0 { - return time.Hour + return 5 * time.Minute } return time.Duration(c.AgentInfoUpdateInterval) } @@ -205,7 +205,7 @@ func Load(path string) (*Config, error) { CurrentVersion: Version, // Use injected version AutoUpdate: true, // Enabled by default; updates require a published SHA256 checksum and a semver-newer release (see internal/updater) AutoUpdateInterval: Duration(24 * time.Hour), - AgentInfoUpdateInterval: Duration(time.Hour), + AgentInfoUpdateInterval: Duration(5 * time.Minute), TaskPollingInterval: Duration(5 * time.Minute), EnableTaskPolling: true, // Enabled by default; remote task execution is gated by backend RLS (and, as a follow-up, task-script signing) // Supabase configuration — SupabaseURL has no default; it must be set in config.json. diff --git a/internal/main_integration.go b/internal/main_integration.go index aa1e5fb..cbae24c 100644 --- a/internal/main_integration.go +++ b/internal/main_integration.go @@ -145,6 +145,12 @@ func (mi *MainIntegration) configureScheduledTasks() error { return fmt.Errorf("failed to add task %s: %w", task.Name, err) } } + + log.Printf("Scheduler intervals: agent-info=%v, software-sync=%v, auto-update=%v, token-refresh=1m", + mi.cfg.GetAgentInfoUpdateInterval(), + mi.cfg.GetSoftwareInfoUpdateInterval(), + mi.cfg.GetAutoUpdateInterval(), + ) return nil } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 5631d90..d11b8c1 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -11,6 +11,7 @@ import ( "sentinelgo/internal/config" "sentinelgo/internal/osinfo" + "sentinelgo/internal/osinfo/shared" "sentinelgo/internal/sanitize" agentsvc "sentinelgo/internal/service/agent" authsvc "sentinelgo/internal/service/auth" @@ -408,9 +409,30 @@ func handleAutoUpdate(ctx context.Context, cfg *config.Config, authSvc *authsvc. return updater.CheckAndApplyWithRetry(ctx, cfg, "") } +// collectTimeout caps the entire agent-info-update cycle (osinfo + RPC). +// osinfo.Collect is synchronous and has no context parameter; without this +// bound, a hung gopsutil call would leave task.Running=true indefinitely and +// silently block every subsequent periodic tick. +const collectTimeout = 90 * time.Second + func handleAgentInfoUpdate(ctx context.Context, cfg *config.Config, _ *authsvc.Service) error { log.Printf("Running agent info update") - sysInfo := osinfo.Collect() + + tctx, cancel := context.WithTimeout(ctx, collectTimeout) + defer cancel() + + type result struct{ info *shared.SystemInfo } + ch := make(chan result, 1) + go func() { ch <- result{osinfo.Collect()} }() + + var sysInfo *shared.SystemInfo + select { + case <-tctx.Done(): + return fmt.Errorf("osinfo.Collect timed out after %v", collectTimeout) + case r := <-ch: + sysInfo = r.info + } + if sysInfo == nil { // osinfo.Collect returns nil when host.Info() fails. Skip this cycle // rather than dereferencing nil downstream (which panicked the task @@ -418,7 +440,7 @@ func handleAgentInfoUpdate(ctx context.Context, cfg *config.Config, _ *authsvc.S return fmt.Errorf("system info collection returned no data; skipping this cycle") } agentSvc := agentsvc.NewAgentService() - return agentSvc.UpdateAgentInfo(ctx, cfg, sysInfo) + return agentSvc.UpdateAgentInfo(tctx, cfg, sysInfo) } func handleSoftwareSync(ctx context.Context, cfg *config.Config, _ *authsvc.Service) error { diff --git a/internal/service/task/executor.go b/internal/service/task/executor.go index f77d70f..b1fc6ed 100644 --- a/internal/service/task/executor.go +++ b/internal/service/task/executor.go @@ -62,27 +62,46 @@ func (s *TaskExecutorService) ExecutePendingTasks(ctx context.Context) { } for _, task := range tasks { - log.Printf("Executor: Starting task %s (%s)", task.ID, task.Slug) - note, err := s.runTask(ctx, task) - if err != nil { - log.Printf("Executor: Task %s failed: %v", task.ID, err) - statusNote := err.Error() - if note != "" { - statusNote = fmt.Sprintf("%s (Error: %v)", note, err) - } - if err := s.pollingSvc.ReportTaskStatus(ctx, task.ID, "failed", statusNote); err != nil { - log.Printf("Executor: Failed to report task status: %v", err) - } - } else { - log.Printf("Executor: Task %s completed successfully", task.ID) - statusNote := note - if statusNote == "" { - statusNote = "Executed successfully" - } - if err := s.pollingSvc.ReportTaskStatus(ctx, task.ID, "success", statusNote); err != nil { - log.Printf("Executor: Failed to report task status: %v", err) - } + s.executeTask(ctx, task) + } +} + +// executeTask marks a single task as executing, runs it, and reports the result. +// Extracted from ExecutePendingTasks to keep complexity within the linter limit. +func (s *TaskExecutorService) executeTask(ctx context.Context, task taskstore.Task) { + // Mark 'executing' in SQLite BEFORE running the script. If the agent is + // killed mid-execution (e.g. by a reboot script), the task stays + // 'executing' on disk. GetAssignedTasks only returns 'assigned' rows, so + // it won't be re-picked on restart. ResetInterruptedTasks (called at + // startup in PollAndStoreTasks) then moves it to 'failed (interrupted)' + // and syncs it to the server. Skipping on error is intentional: if we + // can't write the marker, executing would leave us in the unsafe state. + if err := s.pollingSvc.MarkTaskExecuting(task.ID); err != nil { + log.Printf("Executor: could not mark task %s as 'executing' — skipping to avoid unsafe re-execution: %v", task.ID, err) + return + } + + log.Printf("Executor: Starting task %s (%s)", task.ID, task.Slug) + note, err := s.runTask(ctx, task) + if err != nil { + log.Printf("Executor: Task %s failed: %v", task.ID, err) + statusNote := err.Error() + if note != "" { + statusNote = fmt.Sprintf("%s (Error: %v)", note, err) + } + if err := s.pollingSvc.ReportTaskStatus(ctx, task.ID, "failed", statusNote); err != nil { + log.Printf("Executor: Failed to report task status: %v", err) } + return + } + + log.Printf("Executor: Task %s completed successfully", task.ID) + statusNote := note + if statusNote == "" { + statusNote = "Executed successfully" + } + if err := s.pollingSvc.ReportTaskStatus(ctx, task.ID, "success", statusNote); err != nil { + log.Printf("Executor: Failed to report task status: %v", err) } } diff --git a/internal/service/task/polling.go b/internal/service/task/polling.go index 39d825f..cd77307 100644 --- a/internal/service/task/polling.go +++ b/internal/service/task/polling.go @@ -86,8 +86,24 @@ func (s *TaskPollingService) SetTokenRefresher(refresher TokenRefresher) { s.tokenRefresher = refresher } +// MarkTaskExecuting transitions a task to 'executing' before the script runs. +// See store.TaskStore.MarkTaskExecuting for the safety rationale. +func (s *TaskPollingService) MarkTaskExecuting(taskID string) error { + return s.store.MarkTaskExecuting(taskID) +} + // PollAndStoreTasks fetches tasks from RPC and stores them in SQLite. func (s *TaskPollingService) PollAndStoreTasks(ctx context.Context) error { + // Clean up tasks that were mid-execution when the agent last died. They are + // marked 'failed (interrupted)' with is_synced=0 so SyncPendingTasks below + // will report them to the server. attempt_count is maxed so they are never + // automatically retried (re-running a reboot task would loop forever). + if n, err := s.store.ResetInterruptedTasks(); err != nil { + log.Printf("TaskPolling: failed to reset interrupted tasks: %v", err) + } else if n > 0 { + log.Printf("TaskPolling: marked %d interrupted task(s) as failed (were 'executing' at restart)", n) + } + if resetCount, err := s.store.ResetOldFailedTasks(5 * time.Minute); err != nil { log.Printf("TaskPolling: Failed to reset old failed tasks: %v", err) } else if resetCount > 0 { diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 82cbd2e..337e077 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -193,6 +193,42 @@ func (s *TaskStore) GetAssignedTasks() ([]taskstore.Task, error) { return s.scanTasks(rows) } +// MarkTaskExecuting transitions a task from 'assigned' to 'executing' before +// the script is invoked. This ensures that if the agent is killed mid-execution +// (e.g. by the reboot script it is running), the task is NOT picked up again on +// the next startup — GetAssignedTasks only returns WHERE status = 'assigned'. +func (s *TaskStore) MarkTaskExecuting(taskID string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec(` + UPDATE tasks SET status = 'executing', updated_at = ? WHERE id = ? + `, now, taskID) + return err +} + +// ResetInterruptedTasks marks any task stuck in 'executing' (agent was killed +// mid-run) as 'failed' so it is reported to the server rather than silently +// re-executed. attempt_count is set to maxRetryAttempts so ResetOldFailedTasks +// never automatically recycles these back to 'assigned'. +// Called once per startup, before polling or execution. +func (s *TaskStore) ResetInterruptedTasks() (int, error) { + now := time.Now().UTC().Format(time.RFC3339) + result, err := s.db.Exec(` + UPDATE tasks + SET status = 'failed', + note = 'Agent restarted during task execution; task may have completed. Re-check manually.', + completed_at = ?, + updated_at = ?, + is_synced = 0, + attempt_count = ? + WHERE status = 'executing' + `, now, now, maxRetryAttempts) + if err != nil { + return 0, err + } + n, _ := result.RowsAffected() + return int(n), nil +} + // UpdateTaskStatus updates the status, note, and sync flag for a task. func (s *TaskStore) UpdateTaskStatus(taskID, status, note string, isSynced bool) error { syncedValue := 0 diff --git a/internal/store/tasks_test.go b/internal/store/tasks_test.go new file mode 100644 index 0000000..67f87a1 --- /dev/null +++ b/internal/store/tasks_test.go @@ -0,0 +1,168 @@ +package store + +import ( + "path/filepath" + "testing" + + "sentinelgo/internal/taskstore" +) + +func newTaskStoreForTest(t *testing.T) *TaskStore { + t.Helper() + ts, err := NewTaskStore(filepath.Join(t.TempDir(), "tasks.db")) + if err != nil { + t.Fatalf("NewTaskStore: %v", err) + } + t.Cleanup(func() { _ = ts.Close() }) + return ts +} + +func seedTask(t *testing.T, ts *TaskStore, id, status string) { + t.Helper() + tasks := []taskstore.Task{{ID: id, Name: "test task", Status: status}} + if err := ts.StoreTasks(tasks); err != nil { + t.Fatalf("StoreTasks: %v", err) + } +} + +func queryStatus(t *testing.T, ts *TaskStore, id string) string { + t.Helper() + var status string + if err := ts.db.QueryRow("SELECT status FROM tasks WHERE id = ?", id).Scan(&status); err != nil { + t.Fatalf("query status for %s: %v", id, err) + } + return status +} + +func queryAttemptCount(t *testing.T, ts *TaskStore, id string) int { + t.Helper() + var n int + if err := ts.db.QueryRow("SELECT attempt_count FROM tasks WHERE id = ?", id).Scan(&n); err != nil { + t.Fatalf("query attempt_count for %s: %v", id, err) + } + return n +} + +func queryIsSynced(t *testing.T, ts *TaskStore, id string) int { + t.Helper() + var n int + if err := ts.db.QueryRow("SELECT is_synced FROM tasks WHERE id = ?", id).Scan(&n); err != nil { + t.Fatalf("query is_synced for %s: %v", id, err) + } + return n +} + +// TestMarkTaskExecuting confirms the status transitions from 'assigned' to 'executing'. +func TestMarkTaskExecuting(t *testing.T) { + ts := newTaskStoreForTest(t) + seedTask(t, ts, "task-1", "assigned") + + if err := ts.MarkTaskExecuting("task-1"); err != nil { + t.Fatalf("MarkTaskExecuting: %v", err) + } + + if got := queryStatus(t, ts, "task-1"); got != "executing" { + t.Errorf("status after MarkTaskExecuting = %q, want %q", got, "executing") + } +} + +// TestMarkTaskExecuting_NotPickedUpAgain confirms GetAssignedTasks skips 'executing' tasks. +func TestMarkTaskExecuting_NotPickedUpAgain(t *testing.T) { + ts := newTaskStoreForTest(t) + seedTask(t, ts, "task-exec", "assigned") + + if err := ts.MarkTaskExecuting("task-exec"); err != nil { + t.Fatalf("MarkTaskExecuting: %v", err) + } + + tasks, err := ts.GetAssignedTasks() + if err != nil { + t.Fatalf("GetAssignedTasks: %v", err) + } + for _, task := range tasks { + if task.ID == "task-exec" { + t.Error("GetAssignedTasks returned an 'executing' task — it would be re-executed after restart") + } + } +} + +// TestResetInterruptedTasks confirms 'executing' tasks are moved to 'failed' +// with the correct note, is_synced=0, and attempt_count=maxRetryAttempts. +func TestResetInterruptedTasks(t *testing.T) { + ts := newTaskStoreForTest(t) + seedTask(t, ts, "task-interrupted", "assigned") + if err := ts.MarkTaskExecuting("task-interrupted"); err != nil { + t.Fatalf("MarkTaskExecuting: %v", err) + } + + n, err := ts.ResetInterruptedTasks() + if err != nil { + t.Fatalf("ResetInterruptedTasks: %v", err) + } + if n != 1 { + t.Errorf("ResetInterruptedTasks affected %d rows, want 1", n) + } + + if got := queryStatus(t, ts, "task-interrupted"); got != "failed" { + t.Errorf("status = %q, want %q", got, "failed") + } + if got := queryIsSynced(t, ts, "task-interrupted"); got != 0 { + t.Errorf("is_synced = %d, want 0 (needs server sync)", got) + } + if got := queryAttemptCount(t, ts, "task-interrupted"); got != maxRetryAttempts { + t.Errorf("attempt_count = %d, want %d (max so it isn't auto-retried)", got, maxRetryAttempts) + } +} + +// TestResetInterruptedTasks_NeverRetried confirms that after ResetInterruptedTasks, +// ResetOldFailedTasks will NOT recycle the task back to 'assigned'. +func TestResetInterruptedTasks_NeverRetried(t *testing.T) { + ts := newTaskStoreForTest(t) + seedTask(t, ts, "task-reboot", "assigned") + if err := ts.MarkTaskExecuting("task-reboot"); err != nil { + t.Fatalf("MarkTaskExecuting: %v", err) + } + if _, err := ts.ResetInterruptedTasks(); err != nil { + t.Fatalf("ResetInterruptedTasks: %v", err) + } + + // Simulate several startup cycles — should never move back to 'assigned'. + for i := 0; i < 5; i++ { + if _, err := ts.ResetOldFailedTasks(0); err != nil { + t.Fatalf("ResetOldFailedTasks cycle %d: %v", i, err) + } + if got := queryStatus(t, ts, "task-reboot"); got != "failed" { + t.Errorf("cycle %d: status = %q, want %q — interrupted task was recycled to re-execute", i, got, "failed") + } + } +} + +// TestResetInterruptedTasks_IgnoresOtherStatuses confirms that 'assigned', 'success', +// and 'failed' tasks are untouched by ResetInterruptedTasks. +func TestResetInterruptedTasks_IgnoresOtherStatuses(t *testing.T) { + ts := newTaskStoreForTest(t) + seedTask(t, ts, "task-assigned", "assigned") + seedTask(t, ts, "task-success", "assigned") + seedTask(t, ts, "task-failed", "assigned") + + _ = ts.UpdateTaskStatus("task-success", "success", "", true) + _ = ts.UpdateTaskStatus("task-failed", "failed", "err", false) + + n, err := ts.ResetInterruptedTasks() + if err != nil { + t.Fatalf("ResetInterruptedTasks: %v", err) + } + if n != 0 { + t.Errorf("ResetInterruptedTasks affected %d rows, want 0 (no 'executing' tasks)", n) + } + + if got := queryStatus(t, ts, "task-assigned"); got != "assigned" { + t.Errorf("assigned task changed to %q", got) + } + if got := queryStatus(t, ts, "task-success"); got != "success" { + t.Errorf("success task changed to %q", got) + } + if got := queryStatus(t, ts, "task-failed"); got != "failed" { + t.Errorf("failed task changed to %q", got) + } +} diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 9bb5dad..213cfcc 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -83,19 +83,17 @@ func CheckAndApply(ctx context.Context, cfg *config.Config, token string) error return nil } - assetURL, expectedChecksum, err := selectAssetWithChecksum(latest, runtime.GOOS, runtime.GOARCH) + assetURL, expectedChecksum, sigURL, err := selectAssetWithChecksum(latest, runtime.GOOS, runtime.GOARCH) if err != nil { return fmt.Errorf("select asset: %w", err) } - // M2: only download from a trusted HTTPS GitHub host. + // Only download from a trusted HTTPS GitHub host. if err := validateGitHubURL(assetURL); err != nil { return fmt.Errorf("refusing to download release asset: %w", err) } - // M2: a checksum is mandatory. This is a corruption guard, not authenticity - // (cryptographic signature verification is a deferred follow-up). Fail closed - // rather than installing an unverifiable binary. + // A SHA256 checksum is mandatory as a corruption guard. if expectedChecksum == "" { return fmt.Errorf("refusing to update to %s: no SHA256 checksum published with the release", sanitize.ForLog(latest.TagName)) @@ -111,8 +109,9 @@ func CheckAndApply(ctx context.Context, cfg *config.Config, token string) error fmt.Printf("Created backup: %s\n", backupPath) // Download and verify BEFORE touching the running install. If anything fails - // here, the running agent is untouched. - newPath, actualChecksum, err := downloadAndVerify(ctx, assetURL, expectedChecksum, latest.TagName) + // here, the running agent is untouched. downloadAndVerify verifies both the + // SHA256 checksum and the ed25519 signature; it fails closed on a missing sig. + newPath, actualChecksum, err := downloadAndVerify(ctx, assetURL, expectedChecksum, sigURL, latest.TagName) if err != nil { _ = removeFile(backupPath) return fmt.Errorf("download and verify failed: %w", err) @@ -203,7 +202,11 @@ func fetchLatestRelease(ctx context.Context, cfg *config.Config, token string) ( return &rel, nil } -func selectAssetWithChecksum(rel *GitHubRelease, goos, goarch string) (string, string, error) { +// selectAssetWithChecksum scans the release asset list for the platform binary, +// its .sig file, and the SHA256SUMS file. Returns (assetURL, checksum, sigURL, err). +// sigURL is "" when no matching .sig asset exists; the caller (CheckAndApply) passes +// it to downloadAndVerify which fails closed on an empty sigURL. +func selectAssetWithChecksum(rel *GitHubRelease, goos, goarch string) (assetURL, checksum, sigURL string, err error) { var suffix string switch goos { case "windows": @@ -211,10 +214,12 @@ func selectAssetWithChecksum(rel *GitHubRelease, goos, goarch string) (string, s case "linux", "darwin": suffix = "" default: - return "", "", fmt.Errorf("unsupported OS %s", goos) + return "", "", "", fmt.Errorf("unsupported OS %s", goos) } pattern := fmt.Sprintf("sentinelgo-%s-%s%s", goos, goarch, suffix) + sigPattern := pattern + ".sig" + fmt.Printf("Looking for asset: %s\n", pattern) fmt.Printf("Available assets: %v\n", func() (names []string) { for _, asset := range rel.Assets { @@ -224,28 +229,20 @@ func selectAssetWithChecksum(rel *GitHubRelease, goos, goarch string) (string, s }()) for _, asset := range rel.Assets { - if asset.Name == pattern { - fmt.Printf("Found matching asset: %s\n", asset.Name) - return asset.URL, extractChecksumFromAsset(asset), nil + switch asset.Name { + case pattern: + assetURL = asset.URL + case sigPattern: + sigURL = asset.URL + case "SHA256SUMS", "sha256sums": + checksum = downloadAndParseChecksumFile(asset.URL) } } - return "", "", fmt.Errorf("no matching asset for %s-%s", goos, goarch) -} -// extractChecksumFromAsset extracts SHA256 checksum from asset information. -func extractChecksumFromAsset(asset Asset) string { - if strings.HasSuffix(asset.Name, "SHA256SUMS") || strings.HasSuffix(asset.Name, "sha256sums") { - return downloadAndParseChecksumFile(asset.URL) + if assetURL == "" { + return "", "", "", fmt.Errorf("no matching asset for %s-%s", goos, goarch) } - - if idx := strings.Index(asset.Name, "sha256:"); idx != -1 { - checksum := strings.TrimRight(asset.Name[idx+7:], " )\n\r") - if len(checksum) == 64 { - return checksum - } - } - - return "" + return assetURL, checksum, sigURL, nil } // downloadAndParseChecksumFile downloads a SHA256SUMS file and returns the checksum diff --git a/internal/updater/downloader.go b/internal/updater/downloader.go index 6f8a28b..a463bd3 100644 --- a/internal/updater/downloader.go +++ b/internal/updater/downloader.go @@ -2,20 +2,24 @@ package updater import ( "context" + "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/hex" "fmt" "io" "net/http" "os" + "strings" "sentinelgo/internal/winsec" ) -// downloadAndVerify downloads the binary from url, writes it to a temp file, -// and returns the file path plus the SHA256 of the downloaded bytes. -// The caller is responsible for validating actualChecksum against expectedChecksum. -func downloadAndVerify(ctx context.Context, url, expectedChecksum, version string) (string, string, error) { +// downloadAndVerify downloads the binary from url, writes it to a staged file, +// verifies both the SHA256 checksum (corruption guard) and the ed25519 signature +// (authenticity). sigURL must not be empty — the function fails closed when the +// release contains no .sig asset so a release without a signature is rejected. +func downloadAndVerify(ctx context.Context, url, expectedChecksum, sigURL, version string) (string, string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", "", err @@ -56,9 +60,69 @@ func downloadAndVerify(ctx context.Context, url, expectedChecksum, version strin // Lock the staged binary down so a standard user cannot swap it for a // malicious one between download and the privileged replace/restart. if err := winsec.SecurePath(newPath); err != nil { - // Non-fatal: log and proceed (checksum is still verified by the caller). fmt.Printf("Warning: failed to secure staged binary ACL: %v\n", err) } + // Verify ed25519 signature. Fail closed: a release without a .sig asset is + // rejected. This is the authenticity check; the SHA256 above is only a + // corruption guard. + if err := verifySignature(ctx, newPath, sigURL, PublicKey); err != nil { + _ = os.Remove(newPath) + return "", "", err + } + return newPath, actualChecksum, nil } + +// verifySignature downloads the detached .sig file and verifies it against +// pubKey. Returns an error if sigURL is empty (fail closed), the URL is +// untrusted, the download fails, or the signature is invalid. +func verifySignature(ctx context.Context, binaryPath, sigURL string, pubKey ed25519.PublicKey) error { + if sigURL == "" { + return fmt.Errorf("refusing to install update: no .sig asset in release (fail closed — sign releases with `go run ./scripts/sign`)") + } + + if err := validateGitHubURL(sigURL); err != nil { + return fmt.Errorf("invalid signature URL: %w", err) + } + + sigReq, err := http.NewRequestWithContext(ctx, "GET", sigURL, nil) + if err != nil { + return fmt.Errorf("build sig request: %w", err) + } + sigResp, err := downloadClient.Do(sigReq) + if err != nil { + return fmt.Errorf("download signature: %w", err) + } + defer func() { _ = sigResp.Body.Close() }() + if sigResp.StatusCode != http.StatusOK { + return fmt.Errorf("signature download status %d", sigResp.StatusCode) + } + + sigBody, err := io.ReadAll(sigResp.Body) + if err != nil { + return fmt.Errorf("read signature body: %w", err) + } + sigBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigBody))) + if err != nil { + return fmt.Errorf("decode signature (expected base64): %w", err) + } + + // Read staged binary for verification (binaries are 10–30 MB; acceptable). + binaryBytes, err := os.ReadFile(binaryPath) + if err != nil { + return fmt.Errorf("read staged binary for verification: %w", err) + } + + return verifySignatureBytes(binaryBytes, sigBytes, pubKey) +} + +// verifySignatureBytes checks an ed25519 signature against binaryBytes and pubKey. +// It is separated from verifySignature so unit tests can exercise the crypto +// path without spinning up an HTTP server. +func verifySignatureBytes(binaryBytes, sigBytes []byte, pubKey ed25519.PublicKey) error { + if !ed25519.Verify(pubKey, binaryBytes, sigBytes) { + return fmt.Errorf("ed25519 signature verification failed: binary may have been tampered with") + } + return nil +} diff --git a/internal/updater/pubkey.go b/internal/updater/pubkey.go new file mode 100644 index 0000000..30a244e --- /dev/null +++ b/internal/updater/pubkey.go @@ -0,0 +1,19 @@ +package updater + +import "crypto/ed25519" + +// PublicKey is the ed25519 public key used to verify release binary signatures. +// The matching private key lives in GitHub Actions secret SENTINELGO_SIGNING_KEY. +// All release binaries are signed with this key; the updater rejects any binary +// whose .sig file does not pass ed25519.Verify against this key (fail closed). +// +// To rotate: run `go run ./scripts/keygen`, update this literal and the GitHub +// secret, then tag a new release. The old agent will accept the new binary only +// if it was signed with the OLD key — rotate on a minor/major version bump so +// users can stage the key-rotation release deliberately. +var PublicKey = ed25519.PublicKey{ + 0x62, 0x19, 0xa3, 0x31, 0x1d, 0xc9, 0x4c, 0x07, + 0x60, 0x48, 0xb1, 0xd4, 0x46, 0xd5, 0x2b, 0x36, + 0xe8, 0xa1, 0xd2, 0x6c, 0xcb, 0x67, 0xcf, 0x2f, + 0x25, 0x78, 0x54, 0x28, 0xdb, 0xda, 0xee, 0x7e, +} diff --git a/internal/updater/signing_test.go b/internal/updater/signing_test.go new file mode 100644 index 0000000..9230a15 --- /dev/null +++ b/internal/updater/signing_test.go @@ -0,0 +1,121 @@ +package updater + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +// newTestKeypair generates a fresh ed25519 keypair for each test so tests never +// depend on the embedded PublicKey and cannot interfere with each other. +func newTestKeypair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate test keypair: %v", err) + } + return pub, priv +} + +func writeTempBinary(t *testing.T, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "testbinary") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("write temp binary: %v", err) + } + return path +} + +// TestVerifySignatureBytes_Valid signs binary bytes with a test key and confirms +// that verifySignatureBytes accepts the signature when given the matching public key. +func TestVerifySignatureBytes_Valid(t *testing.T) { + pub, priv := newTestKeypair(t) + data := []byte("fake release binary content") + sig := ed25519.Sign(priv, data) + + if err := verifySignatureBytes(data, sig, pub); err != nil { + t.Errorf("expected valid signature to pass, got: %v", err) + } +} + +// TestVerifySignatureBytes_CorruptedSignature confirms that a single-byte +// corruption in the signature is detected and rejected. +func TestVerifySignatureBytes_CorruptedSignature(t *testing.T) { + pub, priv := newTestKeypair(t) + data := []byte("fake release binary content") + sig := ed25519.Sign(priv, data) + + // Flip one byte in the signature + corrupted := make([]byte, len(sig)) + copy(corrupted, sig) + corrupted[0] ^= 0xff + + if err := verifySignatureBytes(data, corrupted, pub); err == nil { + t.Error("expected corrupted signature to be rejected, got nil error") + } +} + +// TestVerifySignatureBytes_WrongKey signs with key A and verifies with key B — +// the signature must be rejected. +func TestVerifySignatureBytes_WrongKey(t *testing.T) { + _, privA := newTestKeypair(t) + pubB, _ := newTestKeypair(t) + + data := []byte("fake release binary content") + sig := ed25519.Sign(privA, data) + + if err := verifySignatureBytes(data, sig, pubB); err == nil { + t.Error("expected signature from a different key to be rejected, got nil error") + } +} + +// TestVerifySignatureBytes_TamperedBinary signs original bytes but verifies +// against modified bytes — rejects because content changed after signing. +func TestVerifySignatureBytes_TamperedBinary(t *testing.T) { + pub, priv := newTestKeypair(t) + original := []byte("original binary") + sig := ed25519.Sign(priv, original) + + tampered := []byte("tampered binary!") + if err := verifySignatureBytes(tampered, sig, pub); err == nil { + t.Error("expected tampered binary to be rejected, got nil error") + } +} + +// TestVerifySignature_EmptySigURL confirms fail-closed: an empty sigURL is +// rejected without making any network call. +func TestVerifySignature_EmptySigURL(t *testing.T) { + pub, _ := newTestKeypair(t) + binaryPath := writeTempBinary(t, []byte("binary")) + + err := verifySignature(context.Background(), binaryPath, "", pub) + if err == nil { + t.Error("expected empty sigURL to return error (fail closed), got nil") + } +} + +// TestSignTool_RoundTrip exercises the full sign → verify round-trip using the +// same logic the scripts/sign tool uses (ed25519.Sign + base64 encode) and +// verifySignatureBytes. This confirms the wire format is consistent end-to-end. +func TestSignTool_RoundTrip(t *testing.T) { + pub, priv := newTestKeypair(t) + binaryData := []byte("sentinelgo-linux-amd64 fake binary v1.2.3") + + // scripts/sign writes: base64(ed25519.Sign(priv, data)) + "\n" + sigBytes := ed25519.Sign(priv, binaryData) + encoded := base64.StdEncoding.EncodeToString(sigBytes) + "\n" + + // Agent decodes: base64 → raw bytes → ed25519.Verify + decoded, err := base64.StdEncoding.DecodeString(encoded[:len(encoded)-1]) + if err != nil { + t.Fatalf("base64 decode: %v", err) + } + + if err := verifySignatureBytes(binaryData, decoded, pub); err != nil { + t.Errorf("round-trip verification failed: %v", err) + } +} diff --git a/scripts/keygen/main.go b/scripts/keygen/main.go new file mode 100644 index 0000000..d54af81 --- /dev/null +++ b/scripts/keygen/main.go @@ -0,0 +1,52 @@ +// keygen generates a one-time ed25519 signing keypair for SentinelGo release artifacts. +// Run once locally, then discard — never commit the private key. +// +// Usage: +// +// go run ./scripts/keygen +// +// Output: +// - SENTINELGO_SIGNING_KEY → add as a GitHub Actions secret (base64 private key) +// - Public key base64 → save in Supabase for future task-script signing +// - Go byte literal → paste into internal/updater/pubkey.go +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + "os" +) + +func main() { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fmt.Fprintf(os.Stderr, "keygen failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("=== SentinelGo ed25519 Signing Keypair ===") + fmt.Println() + fmt.Println("SENTINELGO_SIGNING_KEY (add to GitHub Actions secrets → never commit):") + fmt.Println(base64.StdEncoding.EncodeToString(priv)) + fmt.Println() + fmt.Println("Public key base64 (save in Supabase for future task-script signing):") + fmt.Println(base64.StdEncoding.EncodeToString(pub)) + fmt.Println() + fmt.Println("Go byte literal for internal/updater/pubkey.go:") + fmt.Println("var PublicKey = ed25519.PublicKey{") + fmt.Printf("\t") + for i, b := range pub { + fmt.Printf("0x%02x,", b) + if i < len(pub)-1 { + if (i+1)%8 == 0 { + fmt.Printf("\n\t") + } else { + fmt.Printf(" ") + } + } + } + fmt.Println() + fmt.Println("}") +} diff --git a/scripts/sign/main.go b/scripts/sign/main.go new file mode 100644 index 0000000..8ae5211 --- /dev/null +++ b/scripts/sign/main.go @@ -0,0 +1,82 @@ +// sign generates ed25519 .sig files and a SHA256SUMS file for SentinelGo release binaries. +// It is called by the release pipeline (CI and `make release`) after binaries are built. +// +// Usage: +// +// SENTINELGO_SIGNING_KEY= go run ./scripts/sign [binary2 ...] +// +// For each binary it writes {binary}.sig (base64-encoded 64-byte ed25519 signature). +// It also writes SHA256SUMS in the same directory as the first binary argument. +package main + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +func main() { + keyB64 := os.Getenv("SENTINELGO_SIGNING_KEY") + if keyB64 == "" { + fmt.Fprintln(os.Stderr, "error: SENTINELGO_SIGNING_KEY env var is not set") + os.Exit(1) + } + + privBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyB64)) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to base64-decode SENTINELGO_SIGNING_KEY: %v\n", err) + os.Exit(1) + } + if len(privBytes) != ed25519.PrivateKeySize { + fmt.Fprintf(os.Stderr, "error: signing key must be %d bytes, got %d\n", + ed25519.PrivateKeySize, len(privBytes)) + os.Exit(1) + } + priv := ed25519.PrivateKey(privBytes) + + binaries := os.Args[1:] + if len(binaries) == 0 { + fmt.Fprintln(os.Stderr, "usage: sign [binary2 ...]") + os.Exit(1) + } + + type entry struct{ name, sum string } + sums := make([]entry, 0, len(binaries)) + + for _, binPath := range binaries { + data, err := os.ReadFile(binPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to read %s: %v\n", binPath, err) + os.Exit(1) + } + + // Sign + sig := ed25519.Sign(priv, data) + sigPath := binPath + ".sig" + if err := os.WriteFile(sigPath, []byte(base64.StdEncoding.EncodeToString(sig)+"\n"), 0644); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to write %s: %v\n", sigPath, err) + os.Exit(1) + } + fmt.Printf("signed %s\n", filepath.Base(binPath)) + + // SHA256 + sum := sha256.Sum256(data) + sums = append(sums, entry{filepath.Base(binPath), fmt.Sprintf("%x", sum)}) + } + + // Write SHA256SUMS in the same directory as the first binary + var sb strings.Builder + for _, e := range sums { + fmt.Fprintf(&sb, "%s %s\n", e.sum, e.name) + } + sha256sumsPath := filepath.Join(filepath.Dir(binaries[0]), "SHA256SUMS") + if err := os.WriteFile(sha256sumsPath, []byte(sb.String()), 0644); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to write SHA256SUMS: %v\n", err) + os.Exit(1) + } + fmt.Printf("wrote %s\n", sha256sumsPath) +} From d522cccdf7f3545c47687aef535f80d5bddf22e5 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 19:52:58 +0600 Subject: [PATCH 4/8] Create dependabot.yml --- .github/dependabot.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d773d8b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,39 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Asia/Dhaka" + open-pull-requests-limit: 10 + labels: + - "dependencies" + groups: + gopsutil: + patterns: + - "github.com/shirou/gopsutil/*" + - "github.com/ebitengine/purego" + - "github.com/lufia/plan9stats" + - "github.com/power-devops/perfstat" + - "github.com/tklauser/*" + - "github.com/yusufpapurcu/wmi" + supabase: + patterns: + - "github.com/supabase-community/*" + sqlite: + patterns: + - "modernc.org/*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Asia/Dhaka" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" From 0f438225304ad58943d5cddfd1b7a365eef61369 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 20:17:29 +0600 Subject: [PATCH 5/8] Add USB mass storage detection & refactors Add cross-platform USB mass storage state collection and related refactors/tests. - macOS: add collectUSBMassStorage which reads MDM plist via plutil or checks kextstat; add JSON parser and tests. Introduce avCrowdStrikeFalcon constant and small struct formatting changes. - Linux: detect usb_storage via /sys/module or modprobe.d blacklists (scan *.conf); add helpers usbStorageStateFromModprobeDir and usbStorageStateFromConfFile with tests. Refactor SELinux and SecureBoot parsing into standalone helpers and use filepath for efivars lookup. Extract AV service probing into probeAVService. - Windows: add collectUSBMassStorage that queries USBSTOR Start DWORD and maps it to enabled/disabled with tests for mapping. - Shared: add USBMassStorageEnabled field to SecurityInfo JSON type. - CI: ensure shell scripts are executable in release workflow. These changes enable reporting of whether removable USB mass storage is permitted, improve parsing/testability of several platform checks, and make release scripts executable. --- .github/workflows/release.yml | 3 + internal/osinfo/security/security_darwin.go | 73 +++++++- .../osinfo/security/security_darwin_test.go | 23 +++ internal/osinfo/security/security_linux.go | 156 +++++++++++++----- .../osinfo/security/security_linux_test.go | 65 ++++++++ internal/osinfo/security/security_windows.go | 39 ++++- .../osinfo/security/security_windows_test.go | 21 +++ internal/osinfo/shared/types.go | 13 +- 8 files changed, 332 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff1617b..c1ded4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,9 @@ jobs: go-version: ${{ env.GO_VERSION }} cache: true + - name: Ensure shell scripts are executable + run: chmod +x installation-doc/install.sh scripts/*.sh + - name: Build all platforms run: make all VERSION="${GITHUB_REF#refs/tags/}" diff --git a/internal/osinfo/security/security_darwin.go b/internal/osinfo/security/security_darwin.go index 9749b71..4397984 100644 --- a/internal/osinfo/security/security_darwin.go +++ b/internal/osinfo/security/security_darwin.go @@ -1,21 +1,24 @@ package security import ( + "encoding/json" "os" "strings" "sentinelgo/internal/osinfo/shared" ) +const avCrowdStrikeFalcon = "CrowdStrike Falcon" + // knownAVApps maps known AV installation paths to product names. var knownAVApps = []struct { path string name string }{ {"/Applications/Malwarebytes.app", "Malwarebytes"}, - {"/Applications/Falcon.app", "CrowdStrike Falcon"}, - {"/Library/CS/falconctl", "CrowdStrike Falcon"}, - {"/Library/Application Support/CrowdStrike/Falcon", "CrowdStrike Falcon"}, + {"/Applications/Falcon.app", avCrowdStrikeFalcon}, + {"/Library/CS/falconctl", avCrowdStrikeFalcon}, + {"/Library/Application Support/CrowdStrike/Falcon", avCrowdStrikeFalcon}, {"/Applications/SentinelOne Extensions.app", "SentinelOne"}, {"/Applications/ESET Endpoint Security.app", "ESET Endpoint Security"}, {"/Applications/Sophos/Sophos Anti-Virus.app", "Sophos AV"}, @@ -32,12 +35,13 @@ func collectSecurity() shared.SecurityInfo { } } return shared.SecurityInfo{ - AntivirusProducts: collectAV(), - FirewallEnabled: enabled, - FirewallProfiles: profiles, - CoreIsolation: collectCoreIsolation(), - SecureBootEnabled: collectSecureBoot(), - ListeningPorts: collectListeningPorts(), + AntivirusProducts: collectAV(), + FirewallEnabled: enabled, + FirewallProfiles: profiles, + CoreIsolation: collectCoreIsolation(), + SecureBootEnabled: collectSecureBoot(), + ListeningPorts: collectListeningPorts(), + USBMassStorageEnabled: collectUSBMassStorage(), } } @@ -132,3 +136,54 @@ func collectSecureBoot() string { } return "unknown" } + +// collectUSBMassStorage checks whether USB mass storage is permitted on macOS. +// On MDM-managed devices, plutil can read the applicationaccess managed preference +// plist and report the allowUSBRestricted policy directly. On unmanaged devices, +// a loaded IOUSBMassStorageClass kext confirms mass storage is active; otherwise +// the state cannot be determined without root or MDM access. +func collectUSBMassStorage() string { + if state := usbStateFromMDM(); state != "" { + return state + } + if kstat, err := shared.RunCommand("kextstat"); err == nil { + if strings.Contains(kstat, "IOUSBMassStorageClass") { + return "enabled" + } + } + return "unknown" +} + +// usbStateFromMDM reads the allowUSBRestricted key from the MDM-managed +// applicationaccess preference plist. Returns "" when the plist is absent, +// unreadable, or does not contain the key. +func usbStateFromMDM() string { + out, err := shared.RunCommand("plutil", "-convert", "json", "-o", "-", + "/Library/Managed Preferences/com.apple.applicationaccess.plist") + if err != nil { + return "" + } + return parseAllowUSBRestrictedJSON(out) +} + +// parseAllowUSBRestrictedJSON extracts the allowUSBRestricted bool from a JSON +// string produced by plutil. Returns "enabled", "disabled", or "" if the key is +// absent or the input is not valid JSON. +func parseAllowUSBRestrictedJSON(jsonStr string) string { + var prefs map[string]interface{} + if json.Unmarshal([]byte(jsonStr), &prefs) != nil { + return "" + } + v, ok := prefs["allowUSBRestricted"] + if !ok { + return "" + } + allowed, ok := v.(bool) + if !ok { + return "" + } + if allowed { + return "enabled" + } + return "disabled" +} diff --git a/internal/osinfo/security/security_darwin_test.go b/internal/osinfo/security/security_darwin_test.go index f7fbcf0..e92e815 100644 --- a/internal/osinfo/security/security_darwin_test.go +++ b/internal/osinfo/security/security_darwin_test.go @@ -5,6 +5,29 @@ import ( "testing" ) +func TestParseAllowUSBRestrictedJSON(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"allowed true → enabled", `{"allowUSBRestricted":true}`, "enabled"}, + {"allowed false → disabled", `{"allowUSBRestricted":false}`, "disabled"}, + {"key absent → empty", `{"otherKey":true}`, ""}, + {"empty object → empty", `{}`, ""}, + {"invalid json → empty", `not json`, ""}, + {"wrong value type → empty", `{"allowUSBRestricted":"yes"}`, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := parseAllowUSBRestrictedJSON(c.input) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + func TestKnownAVApps(t *testing.T) { for _, app := range knownAVApps { if app.path == "" { diff --git a/internal/osinfo/security/security_linux.go b/internal/osinfo/security/security_linux.go index beb1004..fc4e3ec 100644 --- a/internal/osinfo/security/security_linux.go +++ b/internal/osinfo/security/security_linux.go @@ -2,6 +2,7 @@ package security import ( "os" + "path/filepath" "strings" "sentinelgo/internal/osinfo/shared" @@ -34,15 +35,71 @@ func collectSecurity() shared.SecurityInfo { } } return shared.SecurityInfo{ - AntivirusProducts: collectAV(), - FirewallEnabled: enabled, - FirewallProfiles: profiles, - CoreIsolation: collectCoreIsolation(), - SecureBootEnabled: collectSecureBoot(), - ListeningPorts: collectListeningPorts(), + AntivirusProducts: collectAV(), + FirewallEnabled: enabled, + FirewallProfiles: profiles, + CoreIsolation: collectCoreIsolation(), + SecureBootEnabled: collectSecureBoot(), + ListeningPorts: collectListeningPorts(), + USBMassStorageEnabled: collectUSBMassStorage(), } } +// collectUSBMassStorage checks whether USB mass storage is active or explicitly +// blocked on Linux. +// If the usb_storage kernel module is loaded, a device is (or was recently) +// connected and mass storage is enabled. If the module is not loaded but is +// blacklisted in /etc/modprobe.d, it has been administratively disabled. +// "unknown" is returned when the module is absent but not explicitly blocked — +// this is normal when no USB drive is connected on an otherwise unrestricted system. +func collectUSBMassStorage() string { + if _, err := os.Stat("/sys/module/usb_storage"); err == nil { + return "enabled" + } + if state := usbStorageStateFromModprobeDir("/etc/modprobe.d"); state != "" { + return state + } + return "unknown" +} + +// usbStorageStateFromModprobeDir scans *.conf files in dir for a blacklist or +// install-to-/bin/false directive that disables the usb_storage module. +// Returns "disabled" when found, "" otherwise (including when dir is unreadable). +func usbStorageStateFromModprobeDir(dir string) string { + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") { + continue + } + if state := usbStorageStateFromConfFile(filepath.Join(dir, e.Name())); state != "" { + return state + } + } + return "" +} + +// usbStorageStateFromConfFile checks a single modprobe.d conf file for directives +// that disable usb_storage. Returns "disabled" when found, "" otherwise. +func usbStorageStateFromConfFile(path string) string { + data, err := os.ReadFile(path) // #nosec G304 — path comes from os.ReadDir, not user input + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + l := strings.TrimSpace(strings.ToLower(line)) + if strings.HasPrefix(l, "blacklist") && strings.Contains(l, "usb_storage") { + return "disabled" + } + if strings.HasPrefix(l, "install usb_storage") && strings.Contains(l, "/bin/false") { + return "disabled" + } + } + return "" +} + // collectAV probes systemctl for known AV daemons. func collectAV() []shared.AntivirusProduct { var products []shared.AntivirusProduct @@ -51,29 +108,35 @@ func collectAV() []shared.AntivirusProduct { if seen[svc.name] { continue } - output, err := shared.RunCommand("systemctl", "is-active", svc.service) - if err != nil { - continue - } - state := strings.TrimSpace(output) - if state != "active" && state != "inactive" { - continue + if p, ok := probeAVService(svc.service, svc.name); ok { + seen[svc.name] = true + products = append(products, p) } - seen[svc.name] = true - avEnabled := "disabled" - if state == "active" { - avEnabled = "enabled" - } - products = append(products, shared.AntivirusProduct{ - Name: svc.name, - Enabled: avEnabled, - UpToDate: "unknown", - Source: "service", - }) } return products } +func probeAVService(service, name string) (shared.AntivirusProduct, bool) { + output, err := shared.RunCommand("systemctl", "is-active", service) + if err != nil { + return shared.AntivirusProduct{}, false + } + state := strings.TrimSpace(output) + if state != "active" && state != "inactive" { + return shared.AntivirusProduct{}, false + } + avEnabled := "disabled" + if state == "active" { + avEnabled = "enabled" + } + return shared.AntivirusProduct{ + Name: name, + Enabled: avEnabled, + UpToDate: "unknown", + Source: "service", + }, true +} + // collectFirewallProfiles tries ufw, then firewalld, then iptables. func collectFirewallProfiles() []shared.FirewallProfile { if output, err := shared.RunCommand("ufw", "status"); err == nil { @@ -111,23 +174,31 @@ func collectSELinux() string { } } if output, err := shared.RunCommand("sestatus"); err == nil { - for _, line := range strings.Split(output, "\n") { - lower := strings.ToLower(strings.TrimSpace(line)) - if strings.HasPrefix(lower, "selinux status:") && strings.Contains(lower, "disabled") { - return "disabled" - } - if strings.HasPrefix(lower, "current mode:") { - parts := strings.Fields(line) - if len(parts) > 0 { - mode := strings.ToLower(parts[len(parts)-1]) - if mode == "enforcing" || mode == "permissive" { - return mode - } + if mode := parseSEStatusOutput(output); mode != "" { + return mode + } + } + return "unknown" +} + +// parseSEStatusOutput extracts the SELinux mode from sestatus output. +func parseSEStatusOutput(output string) string { + for _, line := range strings.Split(output, "\n") { + lower := strings.ToLower(strings.TrimSpace(line)) + if strings.HasPrefix(lower, "selinux status:") && strings.Contains(lower, "disabled") { + return "disabled" + } + if strings.HasPrefix(lower, "current mode:") { + parts := strings.Fields(line) + if len(parts) > 0 { + mode := strings.ToLower(parts[len(parts)-1]) + if mode == "enforcing" || mode == "permissive" { + return mode } } } } - return "unknown" + return "" } // collectAppArmor returns true when AppArmor is loaded. @@ -166,14 +237,19 @@ func collectSecureBoot() string { return "disabled" } } - // Fallback: read SecureBoot EFI variable (4-byte attribute header + 1 value byte). - entries, err := os.ReadDir("/sys/firmware/efi/efivars") + return secureBootFromEFIVars("/sys/firmware/efi/efivars") +} + +// secureBootFromEFIVars reads the SecureBoot EFI variable from efivarsDir. +// The variable is 5 bytes: 4-byte attribute header + 1 value byte (1=enabled, 0=disabled). +func secureBootFromEFIVars(efivarsDir string) string { + entries, err := os.ReadDir(efivarsDir) if err != nil { return "unknown" } for _, e := range entries { if strings.HasPrefix(e.Name(), "SecureBoot-") { - data, rerr := os.ReadFile("/sys/firmware/efi/efivars/" + e.Name()) + data, rerr := os.ReadFile(filepath.Join(efivarsDir, e.Name())) if rerr == nil && len(data) >= 5 { if data[4] == 1 { return "enabled" diff --git a/internal/osinfo/security/security_linux_test.go b/internal/osinfo/security/security_linux_test.go index bb1c7a6..0e81074 100644 --- a/internal/osinfo/security/security_linux_test.go +++ b/internal/osinfo/security/security_linux_test.go @@ -1,6 +1,8 @@ package security import ( + "os" + "path/filepath" "strings" "testing" ) @@ -25,6 +27,69 @@ func TestKnownAVServices(t *testing.T) { } } +func writeModprobeConf(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } +} + +func TestUsbStorageStateFromModprobeDir(t *testing.T) { + cases := []struct { + name string + file string + content string + want string + }{ + {"blacklist directive disables", "usb.conf", "blacklist usb_storage\n", "disabled"}, + {"install /bin/false disables", "usb.conf", "install usb_storage /bin/false\n", "disabled"}, + {"unrelated blacklist ignored", "other.conf", "blacklist some_other_module\n", ""}, + {"non-conf file ignored", "usb.txt", "blacklist usb_storage\n", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + writeModprobeConf(t, dir, c.file, c.content) + if got := usbStorageStateFromModprobeDir(dir); got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } + + t.Run("empty dir returns empty", func(t *testing.T) { + if got := usbStorageStateFromModprobeDir(t.TempDir()); got != "" { + t.Errorf("got %q, want empty string", got) + } + }) + t.Run("nonexistent dir returns empty", func(t *testing.T) { + if got := usbStorageStateFromModprobeDir("/nonexistent/modprobe.d"); got != "" { + t.Errorf("got %q, want empty string", got) + } + }) +} + +func TestParseSEStatusOutput(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"enforcing mode", "SELinux status: enabled\nCurrent mode: enforcing\n", "enforcing"}, + {"permissive mode", "SELinux status: enabled\nCurrent mode: permissive\n", "permissive"}, + {"disabled status line", "SELinux status: disabled\n", "disabled"}, + {"empty output", "", ""}, + {"unrecognised mode", "Current mode: confused\n", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := parseSEStatusOutput(c.input) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + func TestKernelLockdownParsing(t *testing.T) { cases := []struct { content string diff --git a/internal/osinfo/security/security_windows.go b/internal/osinfo/security/security_windows.go index 08be4d9..92ed263 100644 --- a/internal/osinfo/security/security_windows.go +++ b/internal/osinfo/security/security_windows.go @@ -18,12 +18,39 @@ func collectSecurity() shared.SecurityInfo { } } return shared.SecurityInfo{ - AntivirusProducts: collectAV(), - FirewallEnabled: enabled, - FirewallProfiles: profiles, - CoreIsolation: collectCoreIsolation(), - SecureBootEnabled: collectSecureBoot(), - ListeningPorts: collectListeningPorts(), + AntivirusProducts: collectAV(), + FirewallEnabled: enabled, + FirewallProfiles: profiles, + CoreIsolation: collectCoreIsolation(), + SecureBootEnabled: collectSecureBoot(), + ListeningPorts: collectListeningPorts(), + USBMassStorageEnabled: collectUSBMassStorage(), + } +} + +// collectUSBMassStorage checks whether the USB Mass Storage driver (USBSTOR) is +// enabled via its service start type in the registry. +// Group Policy / MDM sets Start=4 (SERVICE_DISABLED) to block removable drives. +func collectUSBMassStorage() string { + output, err := shared.RunCommand("reg", "query", + `HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR`, "/v", "Start") + if err != nil { + return "unknown" + } + return usbStorStartToState(parseRegDWORD(output, "Start")) +} + +// usbStorStartToState maps a USBSTOR service Start DWORD value to a state string. +// Start=4 is SERVICE_DISABLED; 0–3 are boot/system/auto/demand (all permit the driver to load). +// parseRegDWORD returns -1 when the value is absent; any other unexpected value → "unknown". +func usbStorStartToState(v int) string { + switch v { + case 4: + return "disabled" + case 0, 1, 2, 3: + return "enabled" + default: + return "unknown" } } diff --git a/internal/osinfo/security/security_windows_test.go b/internal/osinfo/security/security_windows_test.go index 29e3f62..9162325 100644 --- a/internal/osinfo/security/security_windows_test.go +++ b/internal/osinfo/security/security_windows_test.go @@ -101,6 +101,27 @@ HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\DeviceGuard } } +func TestUsbStorStartToState(t *testing.T) { + cases := []struct { + v int + want string + }{ + {4, "disabled"}, // SERVICE_DISABLED — Group Policy / MDM enforcement + {3, "enabled"}, // SERVICE_DEMAND_START + {2, "enabled"}, // SERVICE_AUTO_START + {1, "enabled"}, // SERVICE_SYSTEM_START + {0, "enabled"}, // SERVICE_BOOT_START + {-1, "unknown"}, // parseRegDWORD sentinel for missing key + {5, "unknown"}, // unexpected value + } + for _, c := range cases { + got := usbStorStartToState(c.v) + if got != c.want { + t.Errorf("usbStorStartToState(%d) = %q, want %q", c.v, got, c.want) + } + } +} + func TestParseAVProductsJSON(t *testing.T) { t.Run("array with one enabled product", func(t *testing.T) { // productState 397568 = 0x61100: bits 12-15 = 1 (enabled), bits 4-7 = 0 (up-to-date) diff --git a/internal/osinfo/shared/types.go b/internal/osinfo/shared/types.go index 3b16165..2e79961 100644 --- a/internal/osinfo/shared/types.go +++ b/internal/osinfo/shared/types.go @@ -232,12 +232,13 @@ type UserWithGroup struct { // SecurityInfo aggregates all collected security telemetry. type SecurityInfo struct { - AntivirusProducts []AntivirusProduct `json:"antivirus_products"` - FirewallEnabled bool `json:"firewall_enabled"` - FirewallProfiles []FirewallProfile `json:"firewall_profiles"` - CoreIsolation CoreIsolationInfo `json:"core_isolation"` - SecureBootEnabled string `json:"secure_boot_enabled"` - ListeningPorts []ListeningPort `json:"listening_ports"` + AntivirusProducts []AntivirusProduct `json:"antivirus_products"` + FirewallEnabled bool `json:"firewall_enabled"` + FirewallProfiles []FirewallProfile `json:"firewall_profiles"` + CoreIsolation CoreIsolationInfo `json:"core_isolation"` + SecureBootEnabled string `json:"secure_boot_enabled"` + ListeningPorts []ListeningPort `json:"listening_ports"` + USBMassStorageEnabled string `json:"usb_mass_storage_enabled"` // "enabled", "disabled", "unknown" } // AntivirusProduct describes a single detected endpoint-protection product. From d906c066ccd160f94a90595d0d966ed2d1a52364 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 20:39:56 +0600 Subject: [PATCH 6/8] updated how mac service is installed --- cmd/sentinelgo/service/launchd.go | 48 ++++++++++++++++------------- cmd/sentinelgo/service/lifecycle.go | 9 ++++++ installation-doc/install.sh | 9 ++++-- scripts/diagnose-macos.sh | 2 ++ 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/cmd/sentinelgo/service/launchd.go b/cmd/sentinelgo/service/launchd.go index cda8181..05527bf 100644 --- a/cmd/sentinelgo/service/launchd.go +++ b/cmd/sentinelgo/service/launchd.go @@ -17,25 +17,29 @@ func createLaunchdPlist() error { - Label - com.sentinelgo.agent - ProgramArguments - - /opt/sentinelgo/sentinelgo - -run - - RunAtLoad - - KeepAlive - - StandardOutPath - /var/log/sentinelgo.log - StandardErrorPath - /var/log/sentinelgo.err - WorkingDirectory - /opt/sentinelgo - Comment - SentinelGo Agent v%s - Cross-platform system monitoring + Label + com.sentinelgo.agent + ProgramArguments + + /opt/sentinelgo/sentinelgo + -run + --config + /opt/sentinelgo/.sentinelgo/config.json + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/sentinelgo.log + StandardErrorPath + /var/log/sentinelgo.log + UserName + root + WorkingDirectory + /opt/sentinelgo + Comment + SentinelGo Agent v%s - Cross-platform system monitoring `, version) @@ -54,7 +58,8 @@ func createLaunchdPlist() error { } func loadLaunchdService() error { - if err := exec.Command("launchctl", "load", "-w", "/Library/LaunchDaemons/com.sentinelgo.agent.plist").Run(); err != nil { + // launchctl bootstrap is the supported API on macOS 10.15+; launchctl load is deprecated. + if err := exec.Command("launchctl", "bootstrap", "system", "/Library/LaunchDaemons/com.sentinelgo.agent.plist").Run(); err != nil { return fmt.Errorf("load launchd service: %w", err) } fmt.Println("Loaded launchd service: com.sentinelgo.agent") @@ -62,7 +67,8 @@ func loadLaunchdService() error { } func unloadLaunchdService() error { - if err := exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/com.sentinelgo.agent.plist").Run(); err != nil { + // launchctl bootout is the supported API on macOS 10.15+; launchctl unload is deprecated. + if err := exec.Command("launchctl", "bootout", "system", "/Library/LaunchDaemons/com.sentinelgo.agent.plist").Run(); err != nil { return fmt.Errorf("unload launchd service: %w", err) } fmt.Println("Unloaded launchd service: com.sentinelgo.agent") diff --git a/cmd/sentinelgo/service/lifecycle.go b/cmd/sentinelgo/service/lifecycle.go index 75453f3..4450e07 100644 --- a/cmd/sentinelgo/service/lifecycle.go +++ b/cmd/sentinelgo/service/lifecycle.go @@ -22,6 +22,15 @@ func HandleInstall(svc AgentService) { if runtime.GOOS == "darwin" { fmt.Println("Installing SentinelGo as launchd service...") + // Remove the quarantine flag macOS attaches to downloaded binaries, then + // re-codesign with an ad-hoc identity so Gatekeeper accepts the daemon + // without prompting the user to approve "unidentified developer" software. + // spctl --add registers the binary in the Gatekeeper allowlist. + const binaryPath = "/opt/sentinelgo/sentinelgo" + _ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run() + _ = exec.Command("codesign", "--force", "--sign", "-", binaryPath).Run() + _ = exec.Command("spctl", "--add", binaryPath).Run() + if err := createLaunchdPlist(); err != nil { log.Fatalf("Failed to create launchd plist: %v", err) } diff --git a/installation-doc/install.sh b/installation-doc/install.sh index 594fcc0..d212b8f 100644 --- a/installation-doc/install.sh +++ b/installation-doc/install.sh @@ -167,14 +167,19 @@ setup_directories() { # Set permissions chmod +x "$INSTALL_DIR/$BINARY_NAME" - + local os=$(detect_os) - + # Set permissions based on OS if [[ "$os" == "macos" ]]; then # macOS: Use chown with proper group handling chown -R "$SERVICE_USER" "$INSTALL_DIR" 2>/dev/null || true chmod -R 755 "$INSTALL_DIR" 2>/dev/null || true + # Remove download quarantine flag and register with Gatekeeper so macOS does + # not block the daemon with "cannot be verified for malware" on first run. + xattr -d com.apple.quarantine "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true + codesign --force --sign - "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true + spctl --add "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true elif [[ "$os" == "windows" ]]; then # Windows: Skip ownership change echo "[INFO] Skipping ownership change on Windows" diff --git a/scripts/diagnose-macos.sh b/scripts/diagnose-macos.sh index 7903443..3286449 100644 --- a/scripts/diagnose-macos.sh +++ b/scripts/diagnose-macos.sh @@ -18,6 +18,8 @@ if [ -f "/opt/sentinelgo/sentinelgo" ]; then echo "✅ Binary found at /opt/sentinelgo/sentinelgo" echo " Version: $(/opt/sentinelgo/sentinelgo -version 2>/dev/null || echo 'Unknown')" echo " Permissions: $(ls -la /opt/sentinelgo/sentinelgo)" + echo " Gatekeeper: $(spctl -a -v /opt/sentinelgo/sentinelgo 2>&1 | head -1)" + echo " CodeSign: $(codesign -dv /opt/sentinelgo/sentinelgo 2>&1 | grep -E 'Signature|TeamIdentifier' | tr '\n' ' ')" else echo "❌ Binary not found at /opt/sentinelgo/sentinelgo" echo " Expected location: /opt/sentinelgo/sentinelgo" From 65d08bdd2265efce712bd64084f9bb1298e50b12 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 20:49:36 +0600 Subject: [PATCH 7/8] Update installer.go --- internal/updater/installer.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/updater/installer.go b/internal/updater/installer.go index f7f87a4..06672f1 100644 --- a/internal/updater/installer.go +++ b/internal/updater/installer.go @@ -132,6 +132,17 @@ func rollbackFromBackup(backupPath string) error { return nil } +// recodesignForGatekeeper re-signs the binary with an ad-hoc identity and +// registers its new CDHash with Gatekeeper. Must be called after every atomic +// replace on macOS: spctl --add stores the CDHash at install time, and the +// replaced binary has a completely different hash, so without this launchd +// cannot restart the updated binary (Gatekeeper rejects it silently). +func recodesignForGatekeeper(selfPath string) { + _ = exec.Command("xattr", "-d", "com.apple.quarantine", selfPath).Run() + _ = exec.Command("codesign", "--force", "--sign", "-", selfPath).Run() + _ = exec.Command("spctl", "--add", selfPath).Run() +} + // restart hands control to the OS service manager so the new binary runs. // // On Linux/macOS the binary has ALREADY been replaced in place by atomicReplace @@ -160,6 +171,12 @@ func restart(newPath string) error { if runtime.GOOS == "darwin" { log.Println("Updater: update applied; exiting for launchd (KeepAlive) to relaunch the new binary") + // Re-sign and re-register with Gatekeeper before exit. spctl --add stores + // the binary's CDHash in the policy DB; after atomicReplace the CDHash has + // changed, so the old install-time entry no longer matches. Without this, + // launchd relaunches the new binary but Gatekeeper rejects it and the + // daemon silently never comes back up. + recodesignForGatekeeper(selfPath) os.Exit(0) return nil } From 31e72616698faf779e124e6714ba7c7ce32cf640 Mon Sep 17 00:00:00 2001 From: Tanimul Haque Khan Date: Thu, 11 Jun 2026 23:29:44 +0600 Subject: [PATCH 8/8] ran go fmt + added workflow change --- .github/workflows/ci.yml | 65 ++++++++++++------- .github/workflows/codeql.yml | 45 +++++++++++++ internal/osinfo/security/security_darwin.go | 12 ++-- internal/osinfo/security/security_linux.go | 12 ++-- .../osinfo/security/security_linux_test.go | 14 ++-- internal/osinfo/security/security_windows.go | 12 ++-- .../osinfo/security/security_windows_test.go | 14 ++-- internal/osinfo/shared/types.go | 14 ++-- 8 files changed, 124 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b014df9..039cc7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,10 @@ env: CGO_ENABLED: '0' jobs: - lint: - name: Lint (${{ matrix.goos }}) + # ── fast, platform-agnostic checks ────────────────────────────────────────── + static-checks: + name: Static Checks runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - goos: [linux, darwin, windows] steps: - uses: actions/checkout@v6.0.3 @@ -34,21 +28,41 @@ jobs: cache: true - name: Verify go.mod is tidy - if: matrix.goos == 'linux' run: | go mod tidy git diff --exit-code go.mod go.sum - name: Format check - if: matrix.goos == 'linux' run: | unformatted=$(gofmt -s -l .) - if [ -n "$unformatted" ]; then - echo "Files not formatted (run 'gofmt -s -w .'):" - echo "$unformatted" + [ -z "$unformatted" ] || { echo "$unformatted"; exit 1; } + + - name: Check no CGO + run: | + if grep -rlE '^import "C"' --include="*.go" .; then + echo "CGO import detected — violates CGO_ENABLED=0 policy (see CLAUDE.md)" exit 1 fi + # ── per-GOOS lint: catches issues in platform-specific files ───────────────── + lint: + name: Lint (${{ matrix.goos }}) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + goos: [linux, darwin, windows] + steps: + - uses: actions/checkout@v6.0.3 + + - uses: actions/setup-go@v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Vet env: GOOS: ${{ matrix.goos }} @@ -61,10 +75,10 @@ jobs: with: version: v2.12.2 + # ── security: independent — runs in parallel with lint ─────────────────────── security: name: Security Scan runs-on: ubuntu-latest - needs: lint permissions: contents: read security-events: write @@ -81,6 +95,8 @@ jobs: - name: Verify modules run: go mod verify + # Gosec: insight only — uploads findings to GitHub Security tab but does not + # gate the build. govulncheck and Trivy are the enforcing gates below. - name: Run Gosec uses: securego/gosec@v2.27.1 with: @@ -97,7 +113,7 @@ jobs: - name: Run govulncheck uses: golang/govulncheck-action@v1.0.4 - - name: Run Trivy vulnerability scanner + - name: Run Trivy uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: fs @@ -107,10 +123,11 @@ jobs: exit-code: 1 format: table + # ── tests on real OSes ─────────────────────────────────────────────────────── test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} - needs: lint + needs: [static-checks, lint] permissions: contents: read strategy: @@ -133,14 +150,17 @@ jobs: shell: bash run: go tool cover -func=coverage.out - - name: Upload coverage artifact + # Upload all 3 platform coverage files — the codebase has heavy platform- + # specific implementations (every osinfo module has _linux/_darwin/_windows + # files), so all 3 runs are needed for a meaningful merged coverage report. + - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.os }} path: coverage.out retention-days: 1 - + # ── cross-compile all targets ───────────────────────────────────────────────── build: name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) runs-on: ubuntu-latest @@ -182,15 +202,10 @@ jobs: needs: test if: github.event_name != 'workflow_call' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@v6.0.3 with: fetch-depth: 0 - - uses: actions/setup-go@v6.4.0 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - name: Download all coverage artifacts uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0fafe2c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,45 @@ +name: CodeQL + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: '0 2 * * 1' # weekly Monday 02:00 UTC — catches new CVEs in unchanged code + +permissions: + actions: read + contents: read + security-events: write + +env: + CGO_ENABLED: '0' + +jobs: + analyze: + name: CodeQL Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + + - uses: actions/setup-go@v6.4.0 + with: + go-version: '1.25.11' + cache: true + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + # security-extended adds dataflow/taint-tracking queries on top of the + # default security-and-quality suite — complements Gosec's pattern matching + queries: security-extended + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:go diff --git a/internal/osinfo/security/security_darwin.go b/internal/osinfo/security/security_darwin.go index 4397984..89755a1 100644 --- a/internal/osinfo/security/security_darwin.go +++ b/internal/osinfo/security/security_darwin.go @@ -35,12 +35,12 @@ func collectSecurity() shared.SecurityInfo { } } return shared.SecurityInfo{ - AntivirusProducts: collectAV(), - FirewallEnabled: enabled, - FirewallProfiles: profiles, - CoreIsolation: collectCoreIsolation(), - SecureBootEnabled: collectSecureBoot(), - ListeningPorts: collectListeningPorts(), + AntivirusProducts: collectAV(), + FirewallEnabled: enabled, + FirewallProfiles: profiles, + CoreIsolation: collectCoreIsolation(), + SecureBootEnabled: collectSecureBoot(), + ListeningPorts: collectListeningPorts(), USBMassStorageEnabled: collectUSBMassStorage(), } } diff --git a/internal/osinfo/security/security_linux.go b/internal/osinfo/security/security_linux.go index fc4e3ec..7cd5ceb 100644 --- a/internal/osinfo/security/security_linux.go +++ b/internal/osinfo/security/security_linux.go @@ -35,12 +35,12 @@ func collectSecurity() shared.SecurityInfo { } } return shared.SecurityInfo{ - AntivirusProducts: collectAV(), - FirewallEnabled: enabled, - FirewallProfiles: profiles, - CoreIsolation: collectCoreIsolation(), - SecureBootEnabled: collectSecureBoot(), - ListeningPorts: collectListeningPorts(), + AntivirusProducts: collectAV(), + FirewallEnabled: enabled, + FirewallProfiles: profiles, + CoreIsolation: collectCoreIsolation(), + SecureBootEnabled: collectSecureBoot(), + ListeningPorts: collectListeningPorts(), USBMassStorageEnabled: collectUSBMassStorage(), } } diff --git a/internal/osinfo/security/security_linux_test.go b/internal/osinfo/security/security_linux_test.go index 0e81074..d3c28bf 100644 --- a/internal/osinfo/security/security_linux_test.go +++ b/internal/osinfo/security/security_linux_test.go @@ -36,10 +36,10 @@ func writeModprobeConf(t *testing.T, dir, name, content string) { func TestUsbStorageStateFromModprobeDir(t *testing.T) { cases := []struct { - name string - file string - content string - want string + name string + file string + content string + want string }{ {"blacklist directive disables", "usb.conf", "blacklist usb_storage\n", "disabled"}, {"install /bin/false disables", "usb.conf", "install usb_storage /bin/false\n", "disabled"}, @@ -70,9 +70,9 @@ func TestUsbStorageStateFromModprobeDir(t *testing.T) { func TestParseSEStatusOutput(t *testing.T) { cases := []struct { - name string - input string - want string + name string + input string + want string }{ {"enforcing mode", "SELinux status: enabled\nCurrent mode: enforcing\n", "enforcing"}, {"permissive mode", "SELinux status: enabled\nCurrent mode: permissive\n", "permissive"}, diff --git a/internal/osinfo/security/security_windows.go b/internal/osinfo/security/security_windows.go index 92ed263..3eeca81 100644 --- a/internal/osinfo/security/security_windows.go +++ b/internal/osinfo/security/security_windows.go @@ -18,12 +18,12 @@ func collectSecurity() shared.SecurityInfo { } } return shared.SecurityInfo{ - AntivirusProducts: collectAV(), - FirewallEnabled: enabled, - FirewallProfiles: profiles, - CoreIsolation: collectCoreIsolation(), - SecureBootEnabled: collectSecureBoot(), - ListeningPorts: collectListeningPorts(), + AntivirusProducts: collectAV(), + FirewallEnabled: enabled, + FirewallProfiles: profiles, + CoreIsolation: collectCoreIsolation(), + SecureBootEnabled: collectSecureBoot(), + ListeningPorts: collectListeningPorts(), USBMassStorageEnabled: collectUSBMassStorage(), } } diff --git a/internal/osinfo/security/security_windows_test.go b/internal/osinfo/security/security_windows_test.go index 9162325..5b5cbc8 100644 --- a/internal/osinfo/security/security_windows_test.go +++ b/internal/osinfo/security/security_windows_test.go @@ -106,13 +106,13 @@ func TestUsbStorStartToState(t *testing.T) { v int want string }{ - {4, "disabled"}, // SERVICE_DISABLED — Group Policy / MDM enforcement - {3, "enabled"}, // SERVICE_DEMAND_START - {2, "enabled"}, // SERVICE_AUTO_START - {1, "enabled"}, // SERVICE_SYSTEM_START - {0, "enabled"}, // SERVICE_BOOT_START - {-1, "unknown"}, // parseRegDWORD sentinel for missing key - {5, "unknown"}, // unexpected value + {4, "disabled"}, // SERVICE_DISABLED — Group Policy / MDM enforcement + {3, "enabled"}, // SERVICE_DEMAND_START + {2, "enabled"}, // SERVICE_AUTO_START + {1, "enabled"}, // SERVICE_SYSTEM_START + {0, "enabled"}, // SERVICE_BOOT_START + {-1, "unknown"}, // parseRegDWORD sentinel for missing key + {5, "unknown"}, // unexpected value } for _, c := range cases { got := usbStorStartToState(c.v) diff --git a/internal/osinfo/shared/types.go b/internal/osinfo/shared/types.go index 2e79961..8d62109 100644 --- a/internal/osinfo/shared/types.go +++ b/internal/osinfo/shared/types.go @@ -232,13 +232,13 @@ type UserWithGroup struct { // SecurityInfo aggregates all collected security telemetry. type SecurityInfo struct { - AntivirusProducts []AntivirusProduct `json:"antivirus_products"` - FirewallEnabled bool `json:"firewall_enabled"` - FirewallProfiles []FirewallProfile `json:"firewall_profiles"` - CoreIsolation CoreIsolationInfo `json:"core_isolation"` - SecureBootEnabled string `json:"secure_boot_enabled"` - ListeningPorts []ListeningPort `json:"listening_ports"` - USBMassStorageEnabled string `json:"usb_mass_storage_enabled"` // "enabled", "disabled", "unknown" + AntivirusProducts []AntivirusProduct `json:"antivirus_products"` + FirewallEnabled bool `json:"firewall_enabled"` + FirewallProfiles []FirewallProfile `json:"firewall_profiles"` + CoreIsolation CoreIsolationInfo `json:"core_isolation"` + SecureBootEnabled string `json:"secure_boot_enabled"` + ListeningPorts []ListeningPort `json:"listening_ports"` + USBMassStorageEnabled string `json:"usb_mass_storage_enabled"` // "enabled", "disabled", "unknown" } // AntivirusProduct describes a single detected endpoint-protection product.