diff --git a/Makefile b/Makefile index 1872db6..0c89efc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build server agent clean test lint run-server run-agent help +.PHONY: all build server agent sign clean test lint run-server run-agent help GOCMD=go GOBUILD=$(GOCMD) build @@ -9,14 +9,16 @@ GOMOD=$(GOCMD) mod SERVER_BINARY=./out/gitmdm-server AGENT_BINARY=./out/gitmdm-agent +SIGN_BINARY=./out/gitmdm-sign SERVER_PATH=./cmd/server AGENT_PATH=./cmd/agent +SIGN_PATH=./cmd/sign BUILD_FLAGS=-ldflags="-s -w" -trimpath all: build -build: server agent +build: server agent sign server: $(GOBUILD) $(BUILD_FLAGS) -o $(SERVER_BINARY) $(SERVER_PATH) @@ -24,10 +26,14 @@ server: agent: $(GOBUILD) $(BUILD_FLAGS) -o $(AGENT_BINARY) $(AGENT_PATH) +sign: + $(GOBUILD) $(BUILD_FLAGS) -o $(SIGN_BINARY) $(SIGN_PATH) + clean: $(GOCLEAN) rm -f $(SERVER_BINARY) rm -f $(AGENT_BINARY) + rm -f $(SIGN_BINARY) test: $(GOTEST) -v ./... diff --git a/README.md b/README.md index d31cd56..10544cf 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ The SOC-2 compliance solution for the discerningly paranoid security engineer. ![logo](./media/logo_small.png "gitMDM logo") +## What Happens When a Security Engineer Builds an MDM + +gitMDM is what you get when you ask a security engineer to make an MDM tool. Traditional MDMs operate on the assumption that the central server is trustworthy and should have root access to execute arbitrary code on all endpoints. We think that's insane. + +**Core Security Principle**: A compromise of the MDM server should NOT result in a compromise of all agents reporting to it. + +This is why gitMDM: +- **Cannot execute remote commands** - The server literally lacks the code to push commands to agents +- **Uses cryptographic signatures** - All agent configurations are signed with Sigstore, preventing a compromised server from injecting malicious checks +- **Runs without privileges** - Agents run as regular users, not root/SYSTEM +- **Reports only** - Information flows one way: from agents to server, never the reverse + ## Your Problem Your startup just hit the enterprise sales milestone where someone asks "are you SOC 2 compliant?" Meanwhile, your engineering team runs OpenBSD on ThinkPads, Arch on Frameworks, and that one person still dailying Plan 9. @@ -25,6 +37,7 @@ Your Team: "...continue" ### Why Your Security Team Will Actually Approve This - **Zero Remote Execution**: Can't push commands or install software. The server only receives data. +- **Cryptographically Signed Configs**: All agent configurations require Sigstore signatures. A compromised server can't inject malicious checks. - **No Auto-Updates**: No downloading binaries from the internet. Updates require YOU to rebuild and redeploy. - **Runs as User**: No root, no SYSTEM. Can't execute arbitrary code or modify your system. - **You Own Everything**: Your server, your git repo, your data. No third-party cloud with root access to your fleet. @@ -102,11 +115,47 @@ We detect 11+ desktop environments because your team refuses to standardize. The server literally cannot execute commands. We removed the code. It's not there. +### Configuration Integrity via Sigstore + +Every agent configuration is cryptographically signed using Sigstore's keyless signing: + +```bash +# Sign configuration with your GitHub identity +gitmdm-sign --config cmd/agent/checks.yaml + +# Agent verifies signature at runtime +gitmdm-agent --signed-by "github:yourusername@example.com" +``` + +This means: +- **Configurations are tamper-proof** - Any modification breaks the signature +- **Identity-based trust** - You know exactly who signed each configuration (GitHub, Google, etc.) +- **No key management** - Sigstore handles the PKI complexity +- **Transparency logs** - All signatures are recorded in an immutable ledger + +Even if an attacker compromises your server, they cannot: +- Inject malicious compliance checks +- Modify existing check definitions +- Bypass signature verification on agents + +### Future: Check-Build-Check + +We're building automated remediation that maintains our security principles: +- **Check**: Agent identifies non-compliance +- **Build**: Server generates a fix script (signed, of course) +- **Check**: Agent verifies the fix worked + +Even remediation scripts will require cryptographic signatures. No unsigned code execution, ever. + ## FAQ > "What happens if someone compromises the server?" -Nothing. Perhaps they can clean up the old stale check-in data while they are there. +They get read-only access to compliance reports. They cannot: +- Push commands to agents (no code for it) +- Modify agent behavior (signatures prevent it) +- Install malware (agents don't accept commands) +Perhaps they can clean up the old stale check-in data while they are there. > "What if someone tampers with the agent?" diff --git a/cmd/agent/install.go b/cmd/agent/install.go index 0959be5..8ac4c13 100644 --- a/cmd/agent/install.go +++ b/cmd/agent/install.go @@ -22,8 +22,9 @@ const ( // AgentConfig stores the agent configuration. type AgentConfig struct { - ServerURL string `json:"server_url"` - JoinKey string `json:"join_key"` + ServerURL string `json:"server_url"` + JoinKey string `json:"join_key"` + ValidSigners []string `json:"valid_signers,omitempty"` // Allowed config file signers } // configDir returns the appropriate configuration directory for the platform. @@ -99,7 +100,7 @@ func installExecutable(exePath, targetPath string) error { } // installAgent installs the agent to run automatically at system startup. -func installAgent(serverURL, joinKey string) error { +func installAgent(serverURL, joinKey string, allowedSigners []string) error { // Get home directory homeDir, err := os.UserHomeDir() if err != nil { @@ -149,8 +150,9 @@ func installAgent(serverURL, joinKey string) error { configPath := filepath.Join(configDir, configName) config := AgentConfig{ - ServerURL: serverURL, - JoinKey: joinKey, + ServerURL: serverURL, + JoinKey: joinKey, + ValidSigners: allowedSigners, } configData, err := json.MarshalIndent(config, "", " ") if err != nil { diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 9128b64..3706099 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -87,7 +87,10 @@ var ( verbose = flag.Bool("verbose", false, "Show all check outputs, not just failures (with --run all)") install = flag.Bool("install", false, "Install agent to run automatically at startup") uninstall = flag.Bool("uninstall", false, "Uninstall agent and remove autostart") - quiet = false // Set to true to suppress INFO logs (used for interactive mode) + signedBy = flag.String("signed-by", "github:t+github@stromberg.org", + "Comma-separated list of provider:identity pairs allowed to sign configs (e.g., github:username, google:email@example.com)") + skipSignatureCheck = flag.Bool("skip-signature-check", false, "Skip signature verification (INSECURE - for development only)") + quiet = false // Set to true to suppress INFO logs (used for interactive mode) ) // Agent represents the gitMDM agent that collects compliance data. @@ -142,7 +145,8 @@ func (a *Agent) handleInstall() error { log.Printf("✓ Device registered as: %s (%s)", a.hostname, a.hardwareID) // Now proceed with installation - if err := installAgent(*server, *join); err != nil { + allowedSigners := parseAllowedSigners(*signedBy) + if err := installAgent(*server, *join, allowedSigners); err != nil { return fmt.Errorf("installation failed: %w", err) } log.Println("✓ Agent installed successfully and will run automatically at startup") @@ -225,6 +229,19 @@ func (a *Agent) configureServerConnection() error { // initializeAgent creates and initializes an Agent instance. func initializeAgent() (*Agent, error) { + // First, verify the embedded checks.yaml is properly signed + switch { + case !*skipSignatureCheck: + if err := verifyEmbeddedConfig(); err != nil { + return nil, fmt.Errorf("configuration signature verification failed: %w", err) + } + case isDevelopmentMode(): + log.Print("[INFO] Development mode: signature verification disabled (running via 'go run')") + default: + log.Print("[WARN] ⚠️ SIGNATURE VERIFICATION SKIPPED (--skip-signature-check flag)") + log.Print("[WARN] Running with UNVERIFIED configuration - this is INSECURE!") + } + var cfg config.Config if err := yaml.Unmarshal(checksConfig, &cfg); err != nil { return nil, fmt.Errorf("failed to parse checks config: %w", err) @@ -247,7 +264,7 @@ func initializeAgent() (*Agent, error) { return &Agent{ config: &cfg, - hardwareID: hardwareID(), + hardwareID: hardwareID(context.Background()), hostname: hostname, user: user, httpClient: &http.Client{ @@ -309,6 +326,10 @@ func checkAndCreatePIDFile() (exists bool, cleanup func()) { } } log.Printf("[INFO] Removing stale PID file for non-existent process %d", oldPID) + // Remove the stale PID file so we can create our own + if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) { + log.Printf("[WARN] Failed to remove stale PID file: %v", err) + } } } @@ -318,7 +339,8 @@ func checkAndCreatePIDFile() (exists bool, cleanup func()) { if err != nil { if os.IsExist(err) { // Another process created the file between our check and now - log.Print("[WARN] PID file was created by another process, exiting") + // This is an actual race condition with another starting instance + log.Print("[INFO] Another agent instance is starting up, exiting") return false, func() {} } log.Printf("[WARN] Failed to create PID file: %v", err) @@ -350,6 +372,28 @@ func checkAndCreatePIDFile() (exists bool, cleanup func()) { return true, cleanup } +// isDevelopmentMode detects if the agent is running via "go run" instead of as a built binary. +func isDevelopmentMode() bool { + // When go run executes, it creates binaries in temp with specific patterns: + // /tmp/go-build####/b###/exe/binary-name + + exePath, err := os.Executable() + if err != nil { + return false + } + + // Resolve symlinks to get the real path + realPath, err := filepath.EvalSymlinks(exePath) + if err != nil { + realPath = exePath + } + + // Check if path matches go run's pattern + // Must be in temp AND contain "go-build" AND have b### directory structure + return strings.Contains(realPath, filepath.Join(os.TempDir(), "go-build")) || + strings.Contains(realPath, filepath.Join("/private"+os.TempDir(), "go-build")) // macOS symlink +} + func main() { // Set up panic recovery first defer func() { @@ -361,6 +405,13 @@ func main() { flag.Parse() + // Auto-detect development mode + isDev := isDevelopmentMode() + if isDev && !*skipSignatureCheck { + // Automatically skip signature checks in development + *skipSignatureCheck = true + } + // Set up file logging (non-fatal if it fails) var logFile *os.File if lf, err := setupLogging(); err != nil { @@ -954,9 +1005,7 @@ func (*Agent) osVersion(ctx context.Context) string { return "unknown" } -func darwinHardwareID() string { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() +func darwinHardwareID(ctx context.Context) string { cmd := exec.CommandContext(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice") output, err := cmd.Output() if err != nil { @@ -982,7 +1031,7 @@ func darwinHardwareID() string { return "" } -func linuxHardwareID() string { +func linuxHardwareID(_ context.Context) string { data, err := os.ReadFile("/sys/class/dmi/id/product_uuid") if err == nil { id := strings.TrimSpace(string(data)) @@ -1007,8 +1056,8 @@ func linuxHardwareID() string { return "" } -func bsdHardwareID() string { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func bsdHardwareID(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "sysctl", "-n", "kern.hostuuid") output, err := cmd.Output() @@ -1025,8 +1074,8 @@ func bsdHardwareID() string { return id } -func solarisHardwareID() string { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func solarisHardwareID(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "hostid") output, err := cmd.Output() @@ -1043,9 +1092,9 @@ func solarisHardwareID() string { return id } -func illumosHardwareID() string { +func illumosHardwareID(ctx context.Context) string { // Try sysinfo first (Illumos specific) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "sysinfo", "-p") output, err := cmd.Output() @@ -1063,11 +1112,11 @@ func illumosHardwareID() string { } } // Fall back to hostid - return solarisHardwareID() + return solarisHardwareID(ctx) } -func windowsHardwareID() string { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func windowsHardwareID(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, wmicCmd, "csproduct", wmicGetArg, "UUID") output, err := cmd.Output() @@ -1091,7 +1140,7 @@ func windowsHardwareID() string { return "" } -func hardwareID() string { +func hardwareID(ctx context.Context) string { start := time.Now() if *debugMode { log.Printf("[DEBUG] Detecting hardware ID for OS: %s", runtime.GOOS) @@ -1100,17 +1149,17 @@ func hardwareID() string { var id string switch runtime.GOOS { case osDarwin: - id = darwinHardwareID() + id = darwinHardwareID(ctx) case osLinux: - id = linuxHardwareID() + id = linuxHardwareID(ctx) case "freebsd", "openbsd", "netbsd", "dragonfly": - id = bsdHardwareID() + id = bsdHardwareID(ctx) case "solaris": - id = solarisHardwareID() + id = solarisHardwareID(ctx) case "illumos": - id = illumosHardwareID() + id = illumosHardwareID(ctx) case osWindows: - id = windowsHardwareID() + id = windowsHardwareID(ctx) default: if *debugMode { log.Printf("[DEBUG] Unsupported OS for hardware ID detection: %s", runtime.GOOS)