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" 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/.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/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/internal/osinfo/security/security_darwin.go b/internal/osinfo/security/security_darwin.go index 9749b71..89755a1 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..7cd5ceb 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..d3c28bf 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..3eeca81 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..5b5cbc8 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..8d62109 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. 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 } 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"