Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,25 +9,31 @@ 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)

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 ./...
Expand Down
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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?"

Expand Down
12 changes: 7 additions & 5 deletions cmd/agent/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
97 changes: 73 additions & 24 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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{
Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand Down