diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6a83094 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + dependencies: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ad2602 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + types: [ opened, synchronize, reopened ] + +permissions: {} # No default permissions + +env: + GO_VERSION: '1.23.4' + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Test + run: | + make lint + make test + make build \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ea2fbce --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,31 @@ +name: CodeQL + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 + + - name: Analyze + uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 \ No newline at end of file diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..2ba1561 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,22 @@ +name: Dependency Review + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Review Dependencies + uses: actions/dependency-review-action@4081bf99e2866ebe428fc0477b69eb4fcda7220a # v4.4.0 + with: + fail-on-severity: critical \ No newline at end of file diff --git a/README.md b/README.md index 0ae177b..7779db7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # gitMDM +![Experimental](https://img.shields.io/badge/status-experimental-orange) +![Go Version](https://img.shields.io/github/go-mod/go-version/codeGROOVE-dev/gitMDM) +![License](https://img.shields.io/github/license/codeGROOVE-dev/gitMDM) +![Go Report Card](https://goreportcard.com/badge/github.com/codeGROOVE-dev/gitMDM) +![Platform Support](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20bsd%20%7C%20windows-blue) + A security-first MDM that proves compliance without compromising your infrastructure. ![logo](./media/logo_small.png "gitMDM logo") @@ -86,7 +92,7 @@ We love Google Cloud Run for our deployment story - check out `./hacks/deploy.sh Attackers can read compliance reports and delete them. That's it. They cannot push commands, install software, or access agent machines. **Why not just use osquery?** -osquery is powerful but requires careful configuration to avoid information leakage. gitMDM is purpose-built for compliance with security as the primary design constraint. +osquery is a great platform to build an MDM on top of, but its cross-platform support is limited. **How do you prevent supply chain attacks?** Agents are built from source, checks are compiled in, and with Sigstore integration, all configurations are cryptographically signed with identity verification. Minimal dependencies. diff --git a/cmd/agent/checks.yaml.sig b/cmd/agent/checks.yaml.sig new file mode 100644 index 0000000..4cd5b75 --- /dev/null +++ b/cmd/agent/checks.yaml.sig @@ -0,0 +1,3 @@ +MEUCIAjhy9Q4QSM9qrHLvH6JgXPZnERJeKwK1iWKHO7kbsRFAiEAkD2+5NR6XLSyirYZ6WL/PwwXaRO6/GdvBDsKl5mIYeE= +--- +LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxekNDQWx5Z0F3SUJBZ0lVZkltMkNkc2hiR05tcW1kQ29jczhYTlhiQTBrd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpVd09ERXdNVGd6TWpBMldoY05NalV3T0RFd01UZzBNakEyV2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVyekVnd2hHdVlVY25Ba3k4Z2ZEbFNMeFFSeVRoWGwxTHZ3Qi8KeHF6SHpqK3dHdFhpbm5QVjBHV2ZhQkhZTlE1MUtJeFJiSHRNNlFPQ2RZRnpxWnBGTHFPQ0FYc3dnZ0YzTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVUyVHh1CmQ5dmd6bERjZDMxK0U3NUw5RkZNOEowd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0pBWURWUjBSQVFIL0JCb3dHSUVXZEN0bmFYUm9kV0pBYzNSeWIyMWlaWEpuTG05eVp6QXNCZ29yQmdFRQpBWU8vTUFFQkJCNW9kSFJ3Y3pvdkwyZHBkR2gxWWk1amIyMHZiRzluYVc0dmIyRjFkR2d3TGdZS0t3WUJCQUdECnZ6QUJDQVFnREI1b2RIUndjem92TDJkcGRHaDFZaTVqYjIwdmJHOW5hVzR2YjJGMWRHZ3dnWXNHQ2lzR0FRUUIKMW5rQ0JBSUVmUVI3QUhrQWR3RGRQVEJxeHNjUk1tTVpIaHlaWnpjQ29rcGV1TjQ4cmYrSGluS0FMeW51amdBQQpBWmlWUVJMcUFBQUVBd0JJTUVZQ0lRREtud3BzbDMrRzZ6bHRZSExsdnVRQzFvN0d5TCtVdVZSSzBtUzRrQXdXCk9nSWhBTEVRRGNJeFdXYU0xa3hXZ1hjOHp5bmdjQThMYmpBWFNKRURaVno1aUFoTE1Bb0dDQ3FHU000OUJBTUQKQTJrQU1HWUNNUURra2NidjJQN0xEZEgrTWJtMHlWK1gxU21vRWZBK3gwaCtnOVV1cmt3YlJyMXdXenpsZmdQWApwRWhzWnA4ZmNLa0NNUURvNFBwWi9XS0FFUzFydnRKY1lTSlBDTFpnZldRQ24wZmovVGd0amRmWGU1TnVnRXV2Cm5CNnVGZ1AxSW15L3RsTT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= \ No newline at end of file diff --git a/cmd/agent/verify.go b/cmd/agent/verify.go new file mode 100644 index 0000000..9df3acf --- /dev/null +++ b/cmd/agent/verify.go @@ -0,0 +1,275 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "strings" +) + +const ( + // ASCII character ranges. + asciiSpace = 32 + asciiTilde = 126 + asciiDelete = 127 + + // Email length limits (RFC 5321). + maxEmailLength = 254 + maxLocalPartLength = 64 + maxDomainPartLength = 253 +) + +// verifySignatureBundle verifies config data against a signature bundle. +// This function can be used with both file-based and embedded signatures. +// Returns the signer's identity in provider:identity format on success. +func verifySignatureBundle(configData, sigData []byte, allowedSigners []string) (string, error) { + if len(configData) == 0 { + return "", errors.New("config data cannot be empty") + } + if len(sigData) == 0 { + return "", errors.New("signature data cannot be empty") + } + if len(allowedSigners) == 0 { + return "", errors.New("no allowed signers specified") + } + + // Parse signature bundle (format: base64sig\n---\ncert) + parts := strings.Split(string(sigData), "\n---\n") + if len(parts) != 2 { + return "", errors.New("invalid signature bundle format") + } + + sigBase64 := strings.TrimSpace(parts[0]) + certData := strings.TrimSpace(parts[1]) + + // Decode signature from base64 + signature, err := base64.StdEncoding.DecodeString(sigBase64) + if err != nil { + return "", fmt.Errorf("failed to decode signature (len=%d): %w", len(sigBase64), err) + } + + // The certificate might be base64-encoded or already in PEM format + var block *pem.Block + block, _ = pem.Decode([]byte(certData)) + if block == nil { + // Try decoding from base64 first + certPEM, err := base64.StdEncoding.DecodeString(certData) + if err != nil { + return "", fmt.Errorf("certificate is neither valid PEM nor base64: %w", err) + } + block, _ = pem.Decode(certPEM) + if block == nil { + return "", errors.New("failed to parse certificate PEM after base64 decode") + } + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse certificate: %w", err) + } + + // Security: Verify certificate validity + // Note: Fulcio certificates are short-lived (typically valid for 10 minutes) + // We don't check expiration because signatures remain valid after cert expires + // But we should verify the certificate was valid at signing time + // For now, we rely on Rekor's transparency log for this validation + + // Extract signer identity and provider from certificate + signerIdentity, provider := extractSignerInfo(cert) + if signerIdentity == "" { + return "", fmt.Errorf("no identity found in certificate (issuer=%s)", cert.Issuer) + } + if provider == "" { + return "", fmt.Errorf("no OIDC provider found in certificate (subject=%s)", cert.Subject) + } + + // Format as provider:identity + qualifiedSigner := fmt.Sprintf("%s:%s", provider, signerIdentity) + + // Check if signer is allowed + allowed := false + for _, allowedSigner := range allowedSigners { + // Support both old format (email only) and new format (provider:identity) + if strings.Contains(allowedSigner, ":") { + // New format: provider:identity + if strings.EqualFold(strings.TrimSpace(allowedSigner), strings.TrimSpace(qualifiedSigner)) { + allowed = true + break + } + } else { + // Legacy format: just email (for backward compatibility) + if strings.EqualFold(strings.TrimSpace(allowedSigner), strings.TrimSpace(signerIdentity)) { + allowed = true + break + } + } + } + + if !allowed { + return "", fmt.Errorf("config signed by unauthorized signer: %s (allowed: %v)", qualifiedSigner, allowedSigners) + } + + // Verify signature using certificate's public key + hash := sha256.Sum256(configData) + + switch key := cert.PublicKey.(type) { + case *ecdsa.PublicKey: + // ECDSA verification (most common for Sigstore) + if !ecdsa.VerifyASN1(key, hash[:], signature) { + return "", fmt.Errorf("invalid_signature:%s", qualifiedSigner) + } + case *rsa.PublicKey: + // RSA verification + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], signature); err != nil { + return "", fmt.Errorf("invalid_signature:%s", qualifiedSigner) + } + default: + return "", fmt.Errorf("unsupported public key type: %T", key) + } + + return qualifiedSigner, nil +} + +// extractSignerInfo extracts the signer identity and OIDC provider from a Fulcio certificate. +// Returns (identity, provider) where identity is the email/username and provider is github/google/etc. +func extractSignerInfo(cert *x509.Certificate) (identity string, provider string) { + var issuer string + + // Extract issuer from Fulcio extension OID 1.3.6.1.4.1.57264.1.1 (issuer) + // Security: This is a simplified extraction that looks for URLs in the extension + // A proper implementation would parse the ASN.1 structure, but for Fulcio + // certificates, this pattern is consistent and safe + for _, ext := range cert.Extensions { + if ext.Id.String() == "1.3.6.1.4.1.57264.1.1" { + issuer = string(ext.Value) + // Extract HTTPS URL from ASN.1 encoded value + if idx := strings.Index(issuer, "https://"); idx >= 0 { + issuer = issuer[idx:] + // Terminate at first non-printable character + for i, ch := range issuer { + if ch < asciiSpace || ch > asciiTilde { + issuer = issuer[:i] + break + } + } + } + break + } + } + + // Determine provider from issuer URL + switch { + case strings.Contains(issuer, "github.com"): + provider = "github" + case strings.Contains(issuer, "accounts.google.com"): + provider = "google" + case strings.Contains(issuer, "login.microsoftonline.com"): + provider = "microsoft" + case strings.Contains(issuer, "gitlab.com"): + provider = "gitlab" + default: + // Use domain from issuer if unknown + if issuer != "" { + if idx := strings.Index(issuer, "://"); idx > 0 { + domain := issuer[idx+3:] + if idx := strings.IndexAny(domain, ":/"); idx > 0 { + domain = domain[:idx] + } + provider = domain + } + } + } + + // Extract identity (email or username) + identity = extractEmailFromCert(cert) + + return identity, provider +} + +// extractEmailFromCert extracts email from a Fulcio certificate. +func extractEmailFromCert(cert *x509.Certificate) string { + // Check Subject Alternative Names first + for _, email := range cert.EmailAddresses { + if email != "" && isValidEmail(email) { + return email + } + } + + // Check Fulcio extension OID 1.3.6.1.4.1.57264.1.1 for email + for _, ext := range cert.Extensions { + if ext.Id.String() == "1.3.6.1.4.1.57264.1.1" { + value := string(ext.Value) + // Simple email extraction: find @ and get surrounding printable chars + if idx := strings.Index(value, "@"); idx > 0 { + start, end := idx, idx+1 + // Scan backwards for email start + for start > 0 && value[start-1] > asciiSpace && value[start-1] < asciiDelete { + start-- + } + // Scan forwards for email end + for end < len(value) && value[end] > asciiSpace && value[end] < asciiDelete { + end++ + } + if email := value[start:end]; isValidEmail(email) { + return email + } + } + } + } + + return "" +} + +// isValidEmail performs basic email validation. +func isValidEmail(email string) bool { + if len(email) < 3 || len(email) > maxEmailLength { + return false + } + + atIdx := strings.LastIndex(email, "@") + if atIdx <= 0 || atIdx >= len(email)-1 { + return false + } + + localPart := email[:atIdx] + domainPart := email[atIdx+1:] + + if localPart == "" || len(localPart) > maxLocalPartLength { + return false + } + + if domainPart == "" || len(domainPart) > maxDomainPartLength { + return false + } + + // Must have at least one dot in domain + if !strings.Contains(domainPart, ".") { + return false + } + + return true +} + +// parseAllowedSigners parses the comma-separated list of allowed signers. +// Supports both provider:identity format (e.g., "github:username") and legacy email format. +func parseAllowedSigners(signers string) []string { + if signers == "" { + return []string{"github:t+github@stromberg.org"} + } + + parts := strings.Split(signers, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/cmd/agent/verify_embedded.go b/cmd/agent/verify_embedded.go new file mode 100644 index 0000000..140f96e --- /dev/null +++ b/cmd/agent/verify_embedded.go @@ -0,0 +1,92 @@ +package main + +import ( + _ "embed" + "errors" + "log" + "strings" +) + +const errorPrefix = "[ERROR] " + +// Embed the signature file for checks.yaml if it exists +// The signature file is created by running: gitmdm-sign --config cmd/agent/checks.yaml +// +//go:embed checks.yaml.sig +var checksConfigSignature []byte + +// verifyEmbeddedConfig verifies the embedded checks.yaml configuration. +func verifyEmbeddedConfig() error { + // Get allowed signers from config or flags + var allowedSigners []string + // First check if we have a saved config with ValidSigners + if cfg, err := loadConfig(); err == nil && len(cfg.ValidSigners) > 0 { + allowedSigners = cfg.ValidSigners + log.Printf("[INFO] Using allowed signers from saved config: %v", allowedSigners) + } else if *signedBy != "" { + // Use command-line flag if provided + allowedSigners = parseAllowedSigners(*signedBy) + log.Printf("[INFO] Using allowed signers from --signed-by flag: %v", allowedSigners) + } else { + // Use default + allowedSigners = []string{"github:t+github@stromberg.org"} + log.Print("[INFO] Using default allowed signer: github:t+github@stromberg.org") + } + + // Check if we have an embedded signature + if len(checksConfigSignature) == 0 || strings.HasPrefix(string(checksConfigSignature), "# Placeholder") { + log.Print("[ERROR] ⚠️ Configuration Not Signed") + log.Print("[ERROR] ") + log.Print("[ERROR] Sign the configuration:") + log.Print("[ERROR] gitmdm-sign --config cmd/agent/checks.yaml") + log.Print("[ERROR] make build") + log.Print("[ERROR] ") + log.Print("[ERROR] Or skip verification (dev only): --skip-signature-check") + return errors.New("unsigned configuration") + } + + // Verify the signature directly using embedded data + signerEmail, err := verifySignatureBundle(checksConfig, checksConfigSignature, allowedSigners) + if err != nil { + // Check if it's an invalid signature (modified file) + if strings.HasPrefix(err.Error(), "invalid_signature:") { + signer := strings.TrimPrefix(err.Error(), "invalid_signature:") + + log.Print("[ERROR] ⚠️ Configuration Modified After Signing") + log.Print(errorPrefix) + log.Printf("[ERROR] The checks.yaml was changed after being signed by: %s", signer) + log.Print(errorPrefix) + log.Print("[ERROR] Re-sign it:") + log.Print("[ERROR] gitmdm-sign --config cmd/agent/checks.yaml") + log.Print("[ERROR] make build") + log.Print(errorPrefix) + log.Print("[ERROR] Or skip verification (dev only): --skip-signature-check") + return errors.New("configuration security check failed") + } + // Check if it's specifically an unauthorized signer + if strings.Contains(err.Error(), "unauthorized signer") { + // Extract who actually signed it + actualSigner := "" + if idx := strings.Index(err.Error(), ": "); idx > 0 { + parts := strings.Split(err.Error()[idx+2:], " (") + if len(parts) > 0 { + actualSigner = parts[0] + } + } + + log.Print("[ERROR] ⚠️ Untrusted Signer") + log.Print(errorPrefix) + log.Printf("[ERROR] Signed by: %s", actualSigner) + log.Printf("[ERROR] Expected: %v", allowedSigners) + log.Print(errorPrefix) + log.Printf("[ERROR] To trust: --signed-by \"%s\"", actualSigner) + log.Print("[ERROR] To skip: --skip-signature-check (dev only)") + } + return err + } + + // Log who signed it + log.Printf("[INFO] ✅ Configuration signed by: %s", signerEmail) + + return nil +} diff --git a/cmd/sign/main.go b/cmd/sign/main.go new file mode 100644 index 0000000..ec03d11 --- /dev/null +++ b/cmd/sign/main.go @@ -0,0 +1,193 @@ +// Package main implements the gitMDM configuration file signing tool. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + // ASCII printable character boundary. + asciiSpace = 32 + + // Email context extraction. + emailContextBefore = 20 + emailContextAfter = 30 + + // File permissions. + sigFileMode = 0o600 +) + +var ( + configFile = flag.String("config", "", "Path to config file to sign") + help = flag.Bool("help", false, "Show help") +) + +func main() { + flag.Usage = func() { + fmt.Fprint(os.Stderr, "gitmdm-sign - Sign gitMDM configuration files with Sigstore\n\n") + fmt.Fprint(os.Stderr, "Usage: gitmdm-sign --config \n\n") + fmt.Fprint(os.Stderr, "This tool signs configuration files using Sigstore keyless signing.\n") + fmt.Fprint(os.Stderr, "Creates a .sig file containing both signature and certificate.\n") + fmt.Fprint(os.Stderr, "It requires cosign to be installed on your system.\n") + fmt.Fprint(os.Stderr, "It will open a browser for OIDC authentication.\n\n") + fmt.Fprint(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintln(os.Stderr) + fmt.Fprint(os.Stderr, "To install cosign:\n") + fmt.Fprint(os.Stderr, " brew install cosign # macOS\n") + fmt.Fprint(os.Stderr, " go install github.com/sigstore/cosign/v2/cmd/cosign@latest # Go\n") + fmt.Fprint(os.Stderr, " See https://docs.sigstore.dev/cosign/installation for other platforms\n") + } + flag.Parse() + + if *help { + flag.Usage() + os.Exit(0) + } + + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + if err := signConfig(*configFile); err != nil { + log.Fatalf("Failed to sign config: %v", err) + } +} + +func signConfig(configPath string) error { + // Check if config file exists + if _, err := os.Stat(configPath); err != nil { + return fmt.Errorf("config file not found: %w", err) + } + + // Check if cosign is installed + cosignPath, err := exec.LookPath("cosign") + if err != nil { + return errors.New("cosign not found in PATH. Please install cosign: https://docs.sigstore.dev/cosign/installation") + } + + // Get absolute path + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Create temp files for signature and certificate + sigPath := absPath + ".sig.tmp" + certPath := absPath + ".cert.tmp" + bundlePath := absPath + ".sig" + + // Clean up temp files on exit (errors are ignored as cleanup is best-effort) + defer func() { _ = os.Remove(sigPath) }() //nolint:errcheck // Best-effort cleanup + defer func() { _ = os.Remove(certPath) }() //nolint:errcheck // Best-effort cleanup + + // Build the cosign command arguments + args := []string{ + "sign-blob", + absPath, + "--output-signature", sigPath, + "--output-certificate", certPath, + } + + // Show what command we're running + fmt.Printf("\nExecuting: cosign %s\n\n", strings.Join(args, " ")) + fmt.Println("This will open your browser for authentication.") + + // Run cosign (not exec, since we need to process the output) + cmd := exec.CommandContext(context.Background(), cosignPath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("cosign signing failed: %w", err) + } + + // Read the signature and certificate + sigData, err := os.ReadFile(sigPath) + if err != nil { + return fmt.Errorf("failed to read signature: %w", err) + } + + certData, err := os.ReadFile(certPath) + if err != nil { + return fmt.Errorf("failed to read certificate: %w", err) + } + + // Combine into our expected format: signature\n---\ncertificate + bundleContent := fmt.Sprintf("%s\n---\n%s", + strings.TrimSpace(string(sigData)), + strings.TrimSpace(string(certData))) + + // Write the bundle + if err := os.WriteFile(bundlePath, []byte(bundleContent), sigFileMode); err != nil { + return fmt.Errorf("failed to write signature bundle: %w", err) + } + + // Try to parse the certificate to show who signed it + if certData, err := os.ReadFile(certPath); err == nil { + showSignerInfo(certData) + } + + fmt.Printf("\n✓ Signature saved to: %s\n", bundlePath) + fmt.Println("✓ The signature will be embedded in the agent binary during build") + fmt.Println("✓ Rebuild the agent to include the signature: make build") + fmt.Println("") + fmt.Println("Note: The .sig file is embedded into the binary at compile time.") + fmt.Println(" External .sig files are only needed for runtime config verification.") + + return nil +} + +// showSignerInfo shows basic signer info from the certificate. +func showSignerInfo(certPEM []byte) { + // Quick scan for provider and email in certificate + certStr := string(certPEM) + + // Detect provider + provider := "provider" + if strings.Contains(certStr, "github.com") { + provider = "github" + } else if strings.Contains(certStr, "accounts.google.com") { + provider = "google" + } + + // Find email + if idx := strings.Index(certStr, "@"); idx > 0 { + // Get a reasonable chunk around the @ + start := idx - emailContextBefore + if start < 0 { + start = 0 + } + end := idx + emailContextAfter + if end > len(certStr) { + end = len(certStr) + } + chunk := certStr[start:end] + + // Find @ again in chunk and extract email-like string + if at := strings.Index(chunk, "@"); at > 0 { + // Simple extraction: take non-space characters around @ + email := "" + for i := at; i >= 0 && chunk[i] > asciiSpace; i-- { + email = string(chunk[i]) + email + } + for i := at + 1; i < len(chunk) && chunk[i] > asciiSpace; i++ { + email += string(chunk[i]) + } + if strings.Contains(email, ".") { + fmt.Printf("\n✓ Signed by: %s:%s\n", provider, email) + fmt.Printf("✓ To allow: --signed-by \"%s:%s\"\n", provider, email) + } + } + } +} diff --git a/go.mod b/go.mod index 8c408fb..b1351e9 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitmdm +module github.com/codeGROOVE-dev/gitMDM go 1.23.0 diff --git a/internal/config/types_test.go b/internal/config/types_test.go new file mode 100644 index 0000000..b97619c --- /dev/null +++ b/internal/config/types_test.go @@ -0,0 +1,67 @@ +package config_test + +import ( + "gitmdm/internal/config" + "os" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestParseChecksYAML(t *testing.T) { + // Load the actual checks.yaml file + data, err := os.ReadFile("../../cmd/agent/checks.yaml") + if err != nil { + t.Fatalf("Failed to read checks.yaml: %v", err) + } + + // Try to parse it + var cfg config.Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("Failed to parse checks.yaml: %v", err) + } + + // Validate we got some checks + if len(cfg.Checks) == 0 { + t.Fatal("No checks were parsed from checks.yaml") + } + + // Validate some expected checks exist + expectedChecks := []string{"hostname", "firewall", "disk_encryption"} + for _, name := range expectedChecks { + if _, exists := cfg.Checks[name]; !exists { + t.Errorf("Expected check '%s' not found in parsed config", name) + } + } + + // Test that we can get commands for different OSes + if check, exists := cfg.Checks["disk_encryption"]; exists { + // Debug: see what's in the map + if linuxVal, ok := check["linux"]; ok { + t.Logf("linux key exists, type: %T", linuxVal) + } else { + t.Log("linux key does not exist") + } + + // Check that Linux has commands + linuxCmds := check.CommandsForOS("linux") + freebsdCmds := check.CommandsForOS("freebsd") + + if len(linuxCmds) == 0 { + t.Error("No Linux commands found for disk_encryption") + } + if len(freebsdCmds) == 0 { + t.Error("No FreeBSD commands found for disk_encryption") + } + } + + // Test a simple check + if check, exists := cfg.Checks["hostname"]; exists { + allCmds := check.CommandsForOS("darwin") // Should fall back to "all" + if len(allCmds) == 0 { + t.Error("No commands found for hostname check on darwin") + } + } + + t.Logf("Successfully parsed %d checks from checks.yaml", len(cfg.Checks)) +}