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")
+ }
+}