diff --git a/CHANGELOG.md b/CHANGELOG.md index f019d7b..f8b4fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.2] - 2025-12-26 + +### Added +- Added MacOS Secure Keyboard Entry detection for supported terminals + +### Changed +- Updated documentation for Secure Keyboard Entry feature +- Improved filename filtering in DumpsterFire component + + ## [1.1.1] - 2025-12-21 ### Added diff --git a/README.md b/README.md index 89e13fc..84f171b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ $ dashlights --details ### Security Checks -Dashlights performs **37 concurrent security checks** across five categories: Identity & Access Management, Operational Security, Repository Hygiene, System Health, and Infrastructure Security. +Dashlights performs **38 concurrent security checks** across five categories: Identity & Access Management, Operational Security, Repository Hygiene, System Health, and Infrastructure Security. πŸ‘‰ **[View the complete list of security signals β†’](SIGNALS.md)** diff --git a/SIGNALS.md b/SIGNALS.md index f63e3e7..8212832 100644 --- a/SIGNALS.md +++ b/SIGNALS.md @@ -19,41 +19,41 @@ Dashlights performs over 35 concurrent security checks, organized into six categ 10. 🐳 **[Exposed Socket](docs/signals/docker_socket.md)** - Checks Docker socket permissions and orphaned DOCKER_HOST [[code](src/signals/docker_socket.go)] 11. πŸ› **[Debug Mode Enabled](docs/signals/debug_enabled.md)** - Detects debug/trace/verbose environment variables [[code](src/signals/debug_enabled.go)] 12. πŸ” **[History Permissions](docs/signals/history_permissions.md)** - Checks shell history files for world-readable permissions [[code](src/signals/history_permissions.go)] -13. ⚠️ **[Insecure Curl Pipe](docs/signals/insecure_curl_pipe.md)** - Detects recent use of curl | bash or curl | sh installers [[code](src/signals/insecure_curl_pipe.go)] -14. πŸ”‘ **[SSH Agent Key Bloat](docs/signals/ssh_agent_bloat.md)** - Detects too many keys in SSH agent (causes MaxAuthTries lockouts) [[code](src/signals/ssh_agent_bloat.go)] -15. πŸ”‘ **[Open Door](docs/signals/ssh_keys.md)** - Detects SSH private keys with incorrect permissions [[code](src/signals/ssh_keys.go)] +13. ⌨️ **[Secure Keyboard Entry](docs/signals/secure_keyboard.md)** - Detects macOS terminal apps running without Secure Keyboard Entry enabled [[code](src/signals/secure_keyboard.go)] +14. ⚠️ **[Insecure Curl Pipe](docs/signals/insecure_curl_pipe.md)** - Detects recent use of curl | bash or curl | sh installers [[code](src/signals/insecure_curl_pipe.go)] +15. πŸ”‘ **[SSH Agent Key Bloat](docs/signals/ssh_agent_bloat.md)** - Detects too many keys in SSH agent (causes MaxAuthTries lockouts) [[code](src/signals/ssh_agent_bloat.go)] +16. πŸ”‘ **[Open Door](docs/signals/ssh_keys.md)** - Detects SSH private keys with incorrect permissions [[code](src/signals/ssh_keys.go)] ## Repository Hygiene -16. πŸ“ **[Unignored Secret](docs/signals/env_not_ignored.md)** - Checks if .env files exist but aren't in .gitignore [[code](src/signals/env_not_ignored.go)] -17. πŸ‘‘ **[Root-Owned Home Files](docs/signals/root_owned_home.md)** - Finds files in $HOME owned by root [[code](src/signals/root_owned_home.go)] -18. πŸ–ŠοΈ **[World-Writable Configs](docs/signals/world_writable_config.md)** - Detects config files with dangerous permissions [[code](src/signals/world_writable_config.go)] -19. πŸ—οΈ **[Dead Letter](docs/signals/untracked_crypto_keys.md)** - Finds cryptographic keys not in .gitignore [[code](src/signals/untracked_crypto_keys.go)] -20. πŸ”„ **[Go Replace Directive](docs/signals/go_replace.md)** - Detects replace directives in go.mod (breaks builds) [[code](src/signals/go_replace.go)] -21. 🐍 **[PyCache Pollution](docs/signals/pycache_pollution.md)** - Checks for __pycache__ directories not properly ignored [[code](src/signals/pycache_pollution.go)] -22. πŸ“¦ **[NPM RC Tokens](docs/signals/npmrc_tokens.md)** - Detects auth tokens in project .npmrc (should be in ~/.npmrc) [[code](src/signals/npmrc_tokens.go)] -23. πŸ¦€ **[Cargo Path Dependencies](docs/signals/cargo_path_deps.md)** - Checks for path dependencies in Cargo.toml [[code](src/signals/cargo_path_deps.go)] -24. πŸ“ **[Missing __init__.py](docs/signals/missing_init_py.md)** - Detects Python packages missing __init__.py files [[code](src/signals/missing_init_py.go)] -25. β˜• **[Snapshot Dependency](docs/signals/snapshot_dependency.md)** - Checks for SNAPSHOT dependencies on release branches (Java/Maven) [[code](src/signals/snapshot_dependency.go)] -26. 🎬 **[Unsafe Workflow](docs/signals/unsafe_workflow.md)** - Detects dangerous GitHub Actions patterns (pwn requests, expression injection) [[code](src/signals/unsafe_workflow.go)] -27. βš“ **[Missing Git Hooks](docs/signals/missing_git_hooks.md)** - Detects when hook manager config exists but hooks aren't installed [[code](src/signals/missing_git_hooks.go)] +17. πŸ“ **[Unignored Secret](docs/signals/env_not_ignored.md)** - Checks if .env files exist but aren't in .gitignore [[code](src/signals/env_not_ignored.go)] +18. πŸ‘‘ **[Root-Owned Home Files](docs/signals/root_owned_home.md)** - Finds files in $HOME owned by root [[code](src/signals/root_owned_home.go)] +19. πŸ–ŠοΈ **[World-Writable Configs](docs/signals/world_writable_config.md)** - Detects config files with dangerous permissions [[code](src/signals/world_writable_config.go)] +20. πŸ—οΈ **[Dead Letter](docs/signals/untracked_crypto_keys.md)** - Finds cryptographic keys not in .gitignore [[code](src/signals/untracked_crypto_keys.go)] +21. πŸ”„ **[Go Replace Directive](docs/signals/go_replace.md)** - Detects replace directives in go.mod (breaks builds) [[code](src/signals/go_replace.go)] +22. 🐍 **[PyCache Pollution](docs/signals/pycache_pollution.md)** - Checks for __pycache__ directories not properly ignored [[code](src/signals/pycache_pollution.go)] +23. πŸ“¦ **[NPM RC Tokens](docs/signals/npmrc_tokens.md)** - Detects auth tokens in project .npmrc (should be in ~/.npmrc) [[code](src/signals/npmrc_tokens.go)] +24. πŸ¦€ **[Cargo Path Dependencies](docs/signals/cargo_path_deps.md)** - Checks for path dependencies in Cargo.toml [[code](src/signals/cargo_path_deps.go)] +25. πŸ“ **[Missing __init__.py](docs/signals/missing_init_py.md)** - Detects Python packages missing __init__.py files [[code](src/signals/missing_init_py.go)] +26. β˜• **[Snapshot Dependency](docs/signals/snapshot_dependency.md)** - Checks for SNAPSHOT dependencies on release branches (Java/Maven) [[code](src/signals/snapshot_dependency.go)] +27. 🎬 **[Unsafe Workflow](docs/signals/unsafe_workflow.md)** - Detects dangerous GitHub Actions patterns (pwn requests, expression injection) [[code](src/signals/unsafe_workflow.go)] +28. βš“ **[Missing Git Hooks](docs/signals/missing_git_hooks.md)** - Detects when hook manager config exists but hooks aren't installed [[code](src/signals/missing_git_hooks.go)] ## System Health -28. πŸ’Ύ **[Full Tank](docs/signals/disk_space.md)** - Alerts when disk usage exceeds 90% [[code](src/signals/disk_space.go)] -29. ♻️ **[Reboot Pending](docs/signals/reboot_pending.md)** - Detects pending system reboot (Linux) [[code](src/signals/reboot_pending.go)] -30. 🧟 **[Zombie Processes](docs/signals/zombie_processes.md)** - Alerts on excessive zombie processes [[code](src/signals/zombie_processes.go)] -31. πŸ’” **[Dangling Symlinks](docs/signals/dangling_symlinks.md)** - Detects symlinks pointing to non-existent targets [[code](src/signals/dangling_symlinks.go)] -32. ⏰ **[Time Drift Detected](docs/signals/time_drift.md)** - Detects drift between system time and filesystem time [[code](src/signals/time_drift.go)] +29. πŸ’Ύ **[Full Tank](docs/signals/disk_space.md)** - Alerts when disk usage exceeds 90% [[code](src/signals/disk_space.go)] +30. ♻️ **[Reboot Pending](docs/signals/reboot_pending.md)** - Detects pending system reboot (Linux) [[code](src/signals/reboot_pending.go)] +31. 🧟 **[Zombie Processes](docs/signals/zombie_processes.md)** - Alerts on excessive zombie processes [[code](src/signals/zombie_processes.go)] +32. πŸ’” **[Dangling Symlinks](docs/signals/dangling_symlinks.md)** - Detects symlinks pointing to non-existent targets [[code](src/signals/dangling_symlinks.go)] +33. ⏰ **[Time Drift Detected](docs/signals/time_drift.md)** - Detects drift between system time and filesystem time [[code](src/signals/time_drift.go)] ## Infrastructure Security (InfraSec) -33. πŸ—οΈ **[Local Terraform State](docs/signals/terraform_state_local.md)** - Checks for local terraform.tfstate files (should use remote state) [[code](src/signals/terraform_state_local.go)] -34. ☸️ **[Root Kube Context](docs/signals/root_kube_context.md)** - Alerts when Kubernetes context uses kube-system namespace [[code](src/signals/root_kube_context.go)] -35. πŸ” **[Dangerous TF_VAR](docs/signals/dangerous_tf_var.md)** - Checks for dangerous Terraform variables in environment (secrets in shell history) [[code](src/signals/dangerous_tf_var.go)] +34. πŸ—οΈ **[Local Terraform State](docs/signals/terraform_state_local.md)** - Checks for local terraform.tfstate files (should use remote state) [[code](src/signals/terraform_state_local.go)] +35. ☸️ **[Root Kube Context](docs/signals/root_kube_context.md)** - Alerts when Kubernetes context uses kube-system namespace [[code](src/signals/root_kube_context.go)] +36. πŸ” **[Dangerous TF_VAR](docs/signals/dangerous_tf_var.md)** - Checks for dangerous Terraform variables in environment (secrets in shell history) [[code](src/signals/dangerous_tf_var.go)] ## Data Sprawl -36. πŸ—‘οΈ **[Dumpster Fire](docs/signals/dumpster_fire.md)** - Detects sensitive files (dumps, logs, keys) in hot zones (Downloads, Desktop, $PWD, /tmp) [[code](src/signals/dumpster_fire.go)] -37. 🦴 **[Rotting Secrets](docs/signals/rotting_secrets.md)** - Detects old (>7 days) sensitive files that may have been forgotten [[code](src/signals/rotting_secrets.go)] - +37. πŸ—‘οΈ **[Dumpster Fire](docs/signals/dumpster_fire.md)** - Detects sensitive files (dumps, logs, keys) in hot zones (Downloads, Desktop, $PWD, /tmp) [[code](src/signals/dumpster_fire.go)] +38. 🦴 **[Rotting Secrets](docs/signals/rotting_secrets.md)** - Detects old (>7 days) sensitive files that may have been forgotten [[code](src/signals/rotting_secrets.go)] diff --git a/docs/signals/secure_keyboard.md b/docs/signals/secure_keyboard.md new file mode 100644 index 0000000..f162501 --- /dev/null +++ b/docs/signals/secure_keyboard.md @@ -0,0 +1,133 @@ +# Secure Keyboard Entry + +## What this is + +This signal detects when Terminal.app, iTerm2, or Ghostty is running **without** Secure Keyboard Entry enabled. + +**Signal behavior**: Only triggers when a terminal application is: +1. Currently running, AND +2. Has Secure Keyboard Entry disabled (or never enabled) + +**Supported applications**: +- **Terminal.app**: macOS built-in terminal +- **iTerm2**: Popular third-party terminal emulator +- **Ghostty**: GPU-accelerated terminal emulator + +**Platform**: macOS only + +## Why this matters + +### Keylogger Protection + +Secure Keyboard Entry is a macOS security feature that prevents other applications from intercepting keystrokes sent to the terminal. Without it enabled: + +- **Credential theft**: Malicious software can capture passwords, API tokens, and other secrets as you type them +- **Command interception**: Attackers can see every command you execute, including those containing sensitive data +- **Session hijacking**: SSH passwords, sudo credentials, and database passwords are all vulnerable + +### Real-World Scenarios + +**Scenario 1: Malware infection** +A malicious application (installed via compromised software or phishing) runs a keylogger in the background. Every time you type `sudo`, enter your password, or paste an API token, it gets captured. + +**Scenario 2: Credential harvesting** +You're working in a coffee shop. Another user on the same network tricks your Mac into running a background process. Without Secure Keyboard Entry, they can harvest your keystrokes remotely. + +**Scenario 3: Development secrets** +You paste AWS credentials, database passwords, or GitHub tokens into your terminal. Without protection, these can be intercepted by any malicious process with accessibility permissions. + +### Developer Hygiene + +For developers, terminals are where sensitive operations happen: +- `ssh` with passwords or passphrases +- `git push` with credentials +- Environment variable exports with API keys +- Database connections with passwords +- AWS/GCP/Azure CLI commands +- Kubernetes `kubectl` with secrets + +## How to remediate + +### Terminal.app + +**Enable Secure Keyboard Entry**: + +1. Open Terminal.app +2. Click **Terminal** in the menu bar +3. Click **Secure Keyboard Entry** to enable it (a checkmark appears when enabled) + +**Or use the keyboard shortcut**: Press `Command + Shift + K` + +**Verify it's enabled**: +```bash +defaults read com.apple.Terminal SecureKeyboardEntry +# Returns 1 when enabled +``` + +### iTerm2 + +**Enable Secure Keyboard Entry**: + +1. Open iTerm2 +2. Click **iTerm2** in the menu bar +3. Click **Secure Keyboard Entry** to enable it (a checkmark appears when enabled) + +**Verify it's enabled**: +```bash +defaults read com.googlecode.iterm2 "Secure Input" +# Returns 1 when enabled +``` + +### Ghostty + +**Enable Secure Keyboard Entry**: + +1. Open Ghostty +2. Click **Ghostty** in the menu bar +3. Click **Secure Keyboard Entry** to enable it (a checkmark appears when enabled) + +**Verify it's enabled**: +```bash +defaults read com.mitchellh.ghostty SecureInput +# Returns 1 when enabled +``` + +### Making it Permanent + +Terminal.app, iTerm2, and Ghostty remember your Secure Keyboard Entry preference. Once enabled, it persists across sessions and restarts. + +**Note**: Some users disable it because it can interfere with certain accessibility features or automation tools. If you need to disable it, understand the security implications. + +## Security best practices + +1. **Always enable Secure Keyboard Entry** when working with sensitive data + +2. **Check the setting regularly**: The setting can be toggled accidentally via keyboard shortcut + +3. **Be aware of limitations**: Secure Keyboard Entry only protects the specific terminal window. Other applications can still be keylogged. + +4. **Use password managers**: Instead of typing passwords, use a password manager with auto-fill + +5. **Use SSH keys**: Instead of SSH passwords, use SSH key authentication + +6. **Use credential helpers**: Configure Git, AWS CLI, and other tools to use secure credential storage + +7. **Avoid pasting secrets**: Use environment variables, secret managers, or credential helpers instead of pasting secrets directly + +## How it Works + +This signal: +1. Enumerates running processes to check if Terminal.app, iTerm2, or Ghostty is running +2. If running, reads the application's preferences plist to check the Secure Keyboard Entry setting +3. Only signals if the app is running AND the setting is disabled + +**Performance**: Uses native process enumeration (~1-2ms) and cached plist reading, well within the 10ms budget. + +## Disabling This Signal + +To disable this signal, set the environment variable: +```bash +export DASHLIGHTS_DISABLE_SECURE_KEYBOARD=1 +``` + +To disable permanently, add the above line to your shell configuration file (`~/.zshrc`, `~/.bashrc`, etc.). diff --git a/go.mod b/go.mod index 55b5579..60d104b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25 require ( github.com/alexflint/go-arg v1.6.0 github.com/fatih/color v1.18.0 + github.com/mitchellh/go-ps v1.0.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,4 +14,5 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/sys v0.25.0 // indirect + howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index 20361aa..f7a6d6a 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -22,5 +25,8 @@ golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/src/signals/internal/filestat/filestat.go b/src/signals/internal/filestat/filestat.go index 26aef9d..7c37fc7 100644 --- a/src/signals/internal/filestat/filestat.go +++ b/src/signals/internal/filestat/filestat.go @@ -81,7 +81,8 @@ func DefaultSensitivePatterns() SensitiveFilePatterns { "backup-", // Common backup prefix }, Substrings: []string{ - "prod", // Production data indicators + "prod", // Production data indicators (matched at word boundaries) + "production", // Full word also matches (e.g., "production-dump.sql") }, } } @@ -112,9 +113,9 @@ func (p *SensitiveFilePatterns) MatchFile(name string) bool { } } - // Check substrings + // Check substrings with word-boundary awareness for _, substr := range p.Substrings { - if strings.Contains(lowerName, substr) { + if containsAtWordBoundary(lowerName, substr) { return true } } @@ -122,6 +123,40 @@ func (p *SensitiveFilePatterns) MatchFile(name string) bool { return false } +// containsAtWordBoundary checks if substr appears in s at word boundaries. +// Word boundaries are: start/end of string, or common delimiters (-_. and space). +// This prevents "prod" from matching "product", "produce", etc. +func containsAtWordBoundary(s, substr string) bool { + idx := 0 + for { + pos := strings.Index(s[idx:], substr) + if pos == -1 { + return false + } + pos += idx // Adjust to absolute position + + // Check if at word boundary + atStart := pos == 0 || isDelimiter(s[pos-1]) + endPos := pos + len(substr) + atEnd := endPos == len(s) || isDelimiter(s[endPos]) + + if atStart && atEnd { + return true + } + + // Move past this occurrence and keep searching + idx = pos + 1 + if idx >= len(s) { + return false + } + } +} + +// isDelimiter returns true if the byte is a common filename delimiter. +func isDelimiter(b byte) bool { + return b == '-' || b == '_' || b == '.' || b == ' ' +} + // ScanDirectory scans a directory for files matching the patterns. // It returns only regular files (no directories, symlinks, etc.). // This function is shallow - it does not recurse into subdirectories. diff --git a/src/signals/internal/filestat/filestat_test.go b/src/signals/internal/filestat/filestat_test.go index 7d8339e..0eea84a 100644 --- a/src/signals/internal/filestat/filestat_test.go +++ b/src/signals/internal/filestat/filestat_test.go @@ -73,10 +73,21 @@ func TestMatchFile_Substrings(t *testing.T) { filename string want bool }{ + // Should match - "prod" at word boundary {"prod in name", "prod-data.csv", true}, {"PROD uppercase", "PROD_BACKUP.tar", true}, - {"production", "production-dump.sql", true}, // contains "prod" - {"my-product", "my-product.csv", true}, // contains "prod" + {"prod at end", "data-prod.csv", true}, + {"prod with dots", "my.prod.config", true}, + {"prod standalone", "prod", true}, + // Should match - "production" substring + {"production", "production-dump.sql", true}, + {"production mid", "my-production-data.csv", true}, + // Should NOT match - "prod" not at word boundary + {"product", "my-product.csv", false}, + {"produce", "produce-list.txt", false}, + {"prodded", "prodded-users.csv", false}, + {"reproductive", "reproductive-data.csv", false}, + // Other non-matches {"dev file", "dev-data.csv", false}, {"test file", "test-data.csv", false}, } @@ -315,3 +326,56 @@ func TestGetHotZoneDirectories(t *testing.T) { } // Note: Not using slices.Contains to avoid Go 1.21+ dependency + +func TestContainsAtWordBoundary(t *testing.T) { + tests := []struct { + name string + s string + substr string + want bool + }{ + // Basic matches + {"at start with delimiter", "prod-data", "prod", true}, + {"at end with delimiter", "data-prod", "prod", true}, + {"standalone", "prod", "prod", true}, + {"with dots", "my.prod.config", "prod", true}, + {"with underscores", "my_prod_data", "prod", true}, + {"with spaces", "my prod data", "prod", true}, + // Not at word boundary + {"product", "product", "prod", false}, + {"production", "production", "prod", false}, + {"reproduce", "reproduce", "prod", false}, + {"my-product", "my-product", "prod", false}, + // Multiple occurrences - should find the valid one + {"product then prod", "product-prod", "prod", true}, + {"prod then product", "prod-product", "prod", true}, + // Edge cases + {"empty string", "", "prod", false}, + {"substr longer than s", "pr", "prod", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containsAtWordBoundary(tt.s, tt.substr); got != tt.want { + t.Errorf("containsAtWordBoundary(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want) + } + }) + } +} + +func TestIsDelimiter(t *testing.T) { + delimiters := []byte{'-', '_', '.', ' '} + nonDelimiters := []byte{'a', 'z', 'A', 'Z', '0', '9', '/', '\\', '@'} + + for _, b := range delimiters { + if !isDelimiter(b) { + t.Errorf("isDelimiter(%q) = false, want true", b) + } + } + + for _, b := range nonDelimiters { + if isDelimiter(b) { + t.Errorf("isDelimiter(%q) = true, want false", b) + } + } +} diff --git a/src/signals/registry.go b/src/signals/registry.go index 62ba1bd..fbd1ba7 100644 --- a/src/signals/registry.go +++ b/src/signals/registry.go @@ -28,6 +28,7 @@ func GetAllSignals() []Signal { NewInsecureCurlPipeSignal(), // Shell history scan NewSSHAgentBloatSignal(), // Unix socket query NewSSHKeysSignal(), // File stat checks + NewSecureKeyboardSignal(), // macOS only - plist read // Repository hygiene signals NewEnvNotIgnoredSignal(), // Reads .gitignore diff --git a/src/signals/secure_keyboard.go b/src/signals/secure_keyboard.go new file mode 100644 index 0000000..21a98ea --- /dev/null +++ b/src/signals/secure_keyboard.go @@ -0,0 +1,195 @@ +package signals + +import ( + "context" + "fmt" + "os" + "runtime" + "strings" + + ps "github.com/mitchellh/go-ps" + "howett.net/plist" + + "github.com/erichs/dashlights/src/signals/internal/fileutil" + "github.com/erichs/dashlights/src/signals/internal/homedirutil" +) + +// appConfig defines the process name and plist settings for each terminal app. +type appConfig struct { + processName string + plistFile string // plist filename in ~/Library/Preferences/ + keyName string + displayName string +} + +// terminalApps lists the terminal applications to check for Secure Keyboard Entry. +var terminalApps = []appConfig{ + {"Terminal", "com.apple.Terminal.plist", "SecureKeyboardEntry", "Terminal.app"}, + {"iTerm2", "com.googlecode.iterm2.plist", "Secure Input", "iTerm2"}, + {"ghostty", "com.mitchellh.ghostty.plist", "SecureInput", "Ghostty"}, +} + +// SecureKeyboardSignal detects when Terminal.app, iTerm2, or Ghostty is running +// without Secure Keyboard Entry enabled, which could allow keyloggers to +// intercept sensitive input like passwords and tokens. +type SecureKeyboardSignal struct { + insecureApps []string // Which apps triggered (for Diagnostic) + + // processLister allows injecting a mock for testing. + processLister func() ([]ps.Process, error) + // plistReader allows injecting a mock for testing. + // Returns true if the setting is enabled, false otherwise. + plistReader func(ctx context.Context, plistFile, keyName string) (bool, error) +} + +// NewSecureKeyboardSignal creates a SecureKeyboardSignal. +func NewSecureKeyboardSignal() *SecureKeyboardSignal { + s := &SecureKeyboardSignal{} + s.processLister = ps.Processes + s.plistReader = s.readPlistKey + return s +} + +// Name returns the human-readable name of the signal. +func (s *SecureKeyboardSignal) Name() string { + return "Insecure Terminal" +} + +// Emoji returns the emoji associated with the signal. +func (s *SecureKeyboardSignal) Emoji() string { + return "⌨️" +} + +// Diagnostic returns a description of the detected issue. +func (s *SecureKeyboardSignal) Diagnostic() string { + if len(s.insecureApps) == 1 { + return fmt.Sprintf("%s is running without Secure Keyboard Entry - keystrokes may be intercepted", s.insecureApps[0]) + } + return fmt.Sprintf("%s are running without Secure Keyboard Entry - keystrokes may be intercepted", + strings.Join(s.insecureApps, " and ")) +} + +// Remediation returns guidance on how to fix the issue. +func (s *SecureKeyboardSignal) Remediation() string { + var steps []string + for _, app := range s.insecureApps { + if app == "Terminal.app" { + steps = append(steps, "Terminal menu > Secure Keyboard Entry") + } else if app == "Ghostty" { + steps = append(steps, "Ghostty menu > Secure Keyboard Entry") + } else { + steps = append(steps, "iTerm2 menu > Secure Keyboard Entry") + } + } + return "Enable via " + strings.Join(steps, "; ") +} + +// Check detects if a terminal is running without Secure Keyboard Entry enabled. +func (s *SecureKeyboardSignal) Check(ctx context.Context) bool { + // macOS only + if runtime.GOOS != "darwin" { + return false + } + + // Check if this signal is disabled via environment variable + if os.Getenv("DASHLIGHTS_DISABLE_SECURE_KEYBOARD") != "" { + return false + } + + // Get all running processes once (efficient) + processes, err := s.processLister() + if err != nil { + return false + } + + // Build set of running process names + running := make(map[string]bool) + for _, p := range processes { + select { + case <-ctx.Done(): + return false + default: + } + running[p.Executable()] = true + } + + // Reset insecureApps for fresh check + s.insecureApps = nil + + // Check each terminal app + for _, app := range terminalApps { + select { + case <-ctx.Done(): + return false + default: + } + + if running[app.processName] { + enabled, err := s.plistReader(ctx, app.plistFile, app.keyName) + if err != nil { + // Can't read plist - assume safe + continue + } + if !enabled { + s.insecureApps = append(s.insecureApps, app.displayName) + } + } + } + + return len(s.insecureApps) > 0 +} + +// readPlistKey reads a preference key from the plist file. +// The plist is located at ~/Library/Preferences/{plistFile}. +func (s *SecureKeyboardSignal) readPlistKey(ctx context.Context, plistFile, keyName string) (bool, error) { + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + path, err := homedirutil.SafeHomePath("Library", "Preferences", plistFile) + if err != nil { + return false, err + } + + return s.readPlistKeyFromPath(ctx, path, keyName) +} + +// readPlistKeyFromPath reads a preference key from a plist file at the given path. +// This is separated from readPlistKey to allow testing with temp files. +func (s *SecureKeyboardSignal) readPlistKeyFromPath(ctx context.Context, path, keyName string) (bool, error) { + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + data, err := fileutil.ReadFileLimited(path, 512*1024) + if err != nil { + return false, err + } + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + + var prefs map[string]interface{} + if _, err := plist.Unmarshal(data, &prefs); err != nil { + return false, err + } + + // Check the key - handles both bool and int (0/1) values + if val, ok := prefs[keyName]; ok { + switch v := val.(type) { + case bool: + return v, nil + case int64: + return v != 0, nil + case uint64: + return v != 0, nil + } + } + + // Key not set = disabled (default is OFF) + return false, nil +} diff --git a/src/signals/secure_keyboard_test.go b/src/signals/secure_keyboard_test.go new file mode 100644 index 0000000..34f0910 --- /dev/null +++ b/src/signals/secure_keyboard_test.go @@ -0,0 +1,662 @@ +package signals + +import ( + "context" + "errors" + "os" + "runtime" + "strings" + "testing" + + ps "github.com/mitchellh/go-ps" +) + +// mockProcess implements ps.Process for testing. +type mockProcess struct { + executable string + pid int + ppid int +} + +func (m *mockProcess) Pid() int { return m.pid } +func (m *mockProcess) PPid() int { return m.ppid } +func (m *mockProcess) Executable() string { return m.executable } + +func TestSecureKeyboardSignal_NonDarwin(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("Skipping non-darwin test on darwin") + } + + signal := NewSecureKeyboardSignal() + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false on non-darwin platform") + } +} + +func TestSecureKeyboardSignal_Disabled(t *testing.T) { + os.Setenv("DASHLIGHTS_DISABLE_SECURE_KEYBOARD", "1") + defer os.Unsetenv("DASHLIGHTS_DISABLE_SECURE_KEYBOARD") + + signal := NewSecureKeyboardSignal() + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false when signal is disabled via environment variable") + } +} + +func TestSecureKeyboardSignal_NoTerminalsRunning(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "some_other_process", pid: 1}, + &mockProcess{executable: "bash", pid: 2}, + }, nil + } + + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false when no terminals are running") + } +} + +func TestSecureKeyboardSignal_TerminalRunning_SKEEnabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "Terminal", pid: 100}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + // SKE is enabled + return true, nil + } + + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false when Terminal is running with SKE enabled") + } +} + +func TestSecureKeyboardSignal_TerminalRunning_SKEDisabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "Terminal", pid: 100}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + // SKE is disabled + return false, nil + } + + ctx := context.Background() + + if !signal.Check(ctx) { + t.Error("Expected true when Terminal is running with SKE disabled") + } + + if len(signal.insecureApps) != 1 || signal.insecureApps[0] != "Terminal.app" { + t.Errorf("Expected insecureApps=['Terminal.app'], got %v", signal.insecureApps) + } + + // Verify metadata + if signal.Name() != "Insecure Terminal" { + t.Errorf("Expected Name()='Insecure Terminal', got '%s'", signal.Name()) + } + if signal.Emoji() != "⌨️" { + t.Errorf("Unexpected emoji: '%s'", signal.Emoji()) + } + if signal.Diagnostic() == "" { + t.Error("Expected non-empty diagnostic") + } + if signal.Remediation() == "" { + t.Error("Expected non-empty remediation") + } +} + +func TestSecureKeyboardSignal_iTerm2Running_SKEEnabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "iTerm2", pid: 200}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + return true, nil + } + + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false when iTerm2 is running with SKE enabled") + } +} + +func TestSecureKeyboardSignal_iTerm2Running_SKEDisabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "iTerm2", pid: 200}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + return false, nil + } + + ctx := context.Background() + + if !signal.Check(ctx) { + t.Error("Expected true when iTerm2 is running with SKE disabled") + } + + if len(signal.insecureApps) != 1 || signal.insecureApps[0] != "iTerm2" { + t.Errorf("Expected insecureApps=['iTerm2'], got %v", signal.insecureApps) + } + + // Verify iTerm2-specific remediation + remediation := signal.Remediation() + if remediation != "Enable via iTerm2 menu > Secure Keyboard Entry" { + t.Errorf("Expected iTerm2-specific remediation, got '%s'", remediation) + } +} + +func TestSecureKeyboardSignal_GhosttyRunning_SKEEnabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "ghostty", pid: 300}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + if plistFile != "com.mitchellh.ghostty.plist" { + t.Fatalf("Unexpected plistFile: %s", plistFile) + } + if keyName != "SecureInput" { + t.Fatalf("Unexpected keyName: %s", keyName) + } + return true, nil + } + + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false when Ghostty is running with SKE enabled") + } +} + +func TestSecureKeyboardSignal_GhosttyRunning_SKEDisabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "ghostty", pid: 300}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + if plistFile != "com.mitchellh.ghostty.plist" { + t.Fatalf("Unexpected plistFile: %s", plistFile) + } + if keyName != "SecureInput" { + t.Fatalf("Unexpected keyName: %s", keyName) + } + return false, nil + } + + ctx := context.Background() + + if !signal.Check(ctx) { + t.Error("Expected true when Ghostty is running with SKE disabled") + } + + if len(signal.insecureApps) != 1 || signal.insecureApps[0] != "Ghostty" { + t.Errorf("Expected insecureApps=['Ghostty'], got %v", signal.insecureApps) + } + + remediation := signal.Remediation() + if remediation != "Enable via Ghostty menu > Secure Keyboard Entry" { + t.Errorf("Expected Ghostty-specific remediation, got '%s'", remediation) + } +} + +func TestSecureKeyboardSignal_BothRunning_TerminalInsecure(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "Terminal", pid: 100}, + &mockProcess{executable: "iTerm2", pid: 200}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + // Terminal has SKE disabled, iTerm2 has SKE enabled + if plistFile == "com.apple.Terminal.plist" { + return false, nil + } + return true, nil + } + + ctx := context.Background() + + if !signal.Check(ctx) { + t.Error("Expected true when Terminal is insecure") + } + + // Only Terminal should be flagged + if len(signal.insecureApps) != 1 || signal.insecureApps[0] != "Terminal.app" { + t.Errorf("Expected insecureApps=['Terminal.app'], got %v", signal.insecureApps) + } +} + +func TestSecureKeyboardSignal_BothRunning_BothInsecure(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "Terminal", pid: 100}, + &mockProcess{executable: "iTerm2", pid: 200}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + // Both have SKE disabled + return false, nil + } + + ctx := context.Background() + + if !signal.Check(ctx) { + t.Error("Expected true when both terminals are insecure") + } + + // Both should be flagged + if len(signal.insecureApps) != 2 { + t.Errorf("Expected 2 insecure apps, got %d: %v", len(signal.insecureApps), signal.insecureApps) + } + + // Verify diagnostic mentions both + diag := signal.Diagnostic() + if !strings.Contains(diag, "Terminal.app") || !strings.Contains(diag, "iTerm2") { + t.Errorf("Expected diagnostic to mention both apps, got: %s", diag) + } + + // Verify remediation mentions both + rem := signal.Remediation() + if !strings.Contains(rem, "Terminal") || !strings.Contains(rem, "iTerm2") { + t.Errorf("Expected remediation to mention both apps, got: %s", rem) + } +} + +func TestSecureKeyboardSignal_ProcessListError(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return nil, errors.New("process list error") + } + + ctx := context.Background() + + if signal.Check(ctx) { + t.Error("Expected false when process listing fails") + } +} + +func TestSecureKeyboardSignal_PlistReadError(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + return []ps.Process{ + &mockProcess{executable: "Terminal", pid: 100}, + }, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + return false, errors.New("plist read error") + } + + ctx := context.Background() + + // Should not signal when plist can't be read (assume safe) + if signal.Check(ctx) { + t.Error("Expected false when plist read fails") + } +} + +func TestSecureKeyboardSignal_ContextCancellation_Processes(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + + callCount := 0 + signal.processLister = func() ([]ps.Process, error) { + // Return many processes to trigger iteration + processes := make([]ps.Process, 100) + for i := range processes { + processes[i] = &mockProcess{executable: "proc", pid: i} + } + return processes, nil + } + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + callCount++ + return false, nil + } + + // Create a pre-cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Should exit immediately + result := signal.Check(ctx) + + if result { + t.Error("Expected false when context is cancelled") + } + + // plistReader should never be called because we exit during process iteration + if callCount > 0 { + t.Errorf("Expected plistReader not to be called, was called %d times", callCount) + } +} + +func TestSecureKeyboardSignal_ContextCancellation_Apps(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + signal := NewSecureKeyboardSignal() + signal.processLister = func() ([]ps.Process, error) { + // Return just 1 process so we get past process loop + return []ps.Process{}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := signal.Check(ctx) + + if result { + t.Error("Expected false when context is cancelled") + } +} + +func TestSecureKeyboardSignal_Metadata(t *testing.T) { + signal := NewSecureKeyboardSignal() + + if signal.Name() != "Insecure Terminal" { + t.Errorf("Unexpected Name: %s", signal.Name()) + } + + if signal.Emoji() != "⌨️" { + t.Errorf("Unexpected Emoji: %s", signal.Emoji()) + } + + // Set insecureApps for Terminal only + signal.insecureApps = []string{"Terminal.app"} + diag := signal.Diagnostic() + if diag == "" { + t.Error("Expected non-empty diagnostic") + } + if signal.Remediation() != "Enable via Terminal menu > Secure Keyboard Entry" { + t.Errorf("Unexpected Terminal remediation: %s", signal.Remediation()) + } + + // Set insecureApps for iTerm2 only + signal.insecureApps = []string{"iTerm2"} + if signal.Remediation() != "Enable via iTerm2 menu > Secure Keyboard Entry" { + t.Errorf("Unexpected iTerm2 remediation: %s", signal.Remediation()) + } + + // Set insecureApps for Ghostty only + signal.insecureApps = []string{"Ghostty"} + if signal.Remediation() != "Enable via Ghostty menu > Secure Keyboard Entry" { + t.Errorf("Unexpected Ghostty remediation: %s", signal.Remediation()) + } + + // Set insecureApps for both + signal.insecureApps = []string{"Terminal.app", "iTerm2"} + diag = signal.Diagnostic() + if !strings.Contains(diag, "Terminal.app and iTerm2") { + t.Errorf("Expected diagnostic to mention both apps, got: %s", diag) + } + rem := signal.Remediation() + if !strings.Contains(rem, "Terminal") || !strings.Contains(rem, "iTerm2") { + t.Errorf("Expected remediation to mention both, got: %s", rem) + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_MissingKey(t *testing.T) { + // Create a temp plist with no SecureKeyboardEntry key + tmpDir := t.TempDir() + plistPath := tmpDir + "/com.apple.Terminal.plist" + + // Write a minimal plist without the key + plistContent := ` + + + + SomeOtherKey + value + +` + err := os.WriteFile(plistPath, []byte(plistContent), 0644) + if err != nil { + t.Fatalf("Failed to write plist: %v", err) + } + + signal := NewSecureKeyboardSignal() + // Override plistReader to use our temp path + signal.plistReader = func(ctx context.Context, plistFile, keyName string) (bool, error) { + return signal.readPlistKeyFromPath(ctx, plistPath, keyName) + } + + enabled, err := signal.plistReader(context.Background(), "", "SecureKeyboardEntry") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Missing key = disabled (default is OFF) + if enabled { + t.Error("Expected false when key is missing") + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_BoolTrue(t *testing.T) { + tmpDir := t.TempDir() + plistPath := tmpDir + "/com.apple.Terminal.plist" + + // Write plist with SecureKeyboardEntry = true + plistContent := ` + + + + SecureKeyboardEntry + + +` + err := os.WriteFile(plistPath, []byte(plistContent), 0644) + if err != nil { + t.Fatalf("Failed to write plist: %v", err) + } + + signal := NewSecureKeyboardSignal() + enabled, err := signal.readPlistKeyFromPath(context.Background(), plistPath, "SecureKeyboardEntry") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !enabled { + t.Error("Expected true when key is set to true") + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_BoolFalse(t *testing.T) { + tmpDir := t.TempDir() + plistPath := tmpDir + "/com.apple.Terminal.plist" + + // Write plist with SecureKeyboardEntry = false + plistContent := ` + + + + SecureKeyboardEntry + + +` + err := os.WriteFile(plistPath, []byte(plistContent), 0644) + if err != nil { + t.Fatalf("Failed to write plist: %v", err) + } + + signal := NewSecureKeyboardSignal() + enabled, err := signal.readPlistKeyFromPath(context.Background(), plistPath, "SecureKeyboardEntry") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if enabled { + t.Error("Expected false when key is set to false") + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_Integer1(t *testing.T) { + tmpDir := t.TempDir() + plistPath := tmpDir + "/com.apple.Terminal.plist" + + // Write plist with SecureKeyboardEntry = 1 (integer) + plistContent := ` + + + + SecureKeyboardEntry + 1 + +` + err := os.WriteFile(plistPath, []byte(plistContent), 0644) + if err != nil { + t.Fatalf("Failed to write plist: %v", err) + } + + signal := NewSecureKeyboardSignal() + enabled, err := signal.readPlistKeyFromPath(context.Background(), plistPath, "SecureKeyboardEntry") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !enabled { + t.Error("Expected true when key is set to integer 1") + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_Integer0(t *testing.T) { + tmpDir := t.TempDir() + plistPath := tmpDir + "/com.apple.Terminal.plist" + + // Write plist with SecureKeyboardEntry = 0 (integer) + plistContent := ` + + + + SecureKeyboardEntry + 0 + +` + err := os.WriteFile(plistPath, []byte(plistContent), 0644) + if err != nil { + t.Fatalf("Failed to write plist: %v", err) + } + + signal := NewSecureKeyboardSignal() + enabled, err := signal.readPlistKeyFromPath(context.Background(), plistPath, "SecureKeyboardEntry") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if enabled { + t.Error("Expected false when key is set to integer 0") + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_MalformedPlist(t *testing.T) { + tmpDir := t.TempDir() + plistPath := tmpDir + "/com.apple.Terminal.plist" + + // Write invalid plist + err := os.WriteFile(plistPath, []byte("not a valid plist"), 0644) + if err != nil { + t.Fatalf("Failed to write plist: %v", err) + } + + signal := NewSecureKeyboardSignal() + _, err = signal.readPlistKeyFromPath(context.Background(), plistPath, "SecureKeyboardEntry") + if err == nil { + t.Error("Expected error for malformed plist") + } +} + +func TestSecureKeyboardSignal_ReadPlistKey_FileNotFound(t *testing.T) { + signal := NewSecureKeyboardSignal() + _, err := signal.readPlistKeyFromPath(context.Background(), "/nonexistent/path/file.plist", "SecureKeyboardEntry") + if err == nil { + t.Error("Expected error for non-existent file") + } +} + +func TestSecureKeyboardSignal_RealProcessCheck(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Skipping darwin-specific test on non-darwin") + } + + // Integration test: actually enumerate processes and read plist + signal := NewSecureKeyboardSignal() + ctx := context.Background() + + // This should not panic or error + _ = signal.Check(ctx) + + // Verify metadata is always available + if signal.Name() == "" { + t.Error("Expected non-empty name") + } + if signal.Emoji() == "" { + t.Error("Expected non-empty emoji") + } +}