Scope: bem, install.sh, bundled plugins (search, replace)
bem manages shell scripts as "plugins" by creating symlinks in ~/.local/bin/ that point to the original script files. Plugin metadata (enabled state, file path, git tracking) is stored as individual files inside ~/.bem/plugins/<name>/.
The only file sourced by .bashrc is a ~2KB autocomplete function. bem itself and all plugins are invoked via symlinks — no aliases, no eval, no dynamic code generation.
| ID | Threat | Impact |
|---|---|---|
| T1 | Shell injection via crafted plugin names or paths | Arbitrary code execution |
| T2 | Path traversal (e.g. ../../../etc/passwd) |
File read/write outside registry |
| T3 | Shadowing system commands (ls, sudo) |
Privilege escalation, user confusion |
| T4 | World-writable plugin scripts | Any local user can backdoor a plugin |
| T5 | Concurrent access corrupting state | Plugin registry inconsistency |
| T6 | Partial writes on crash | Sourced file becomes invalid |
| T7 | Unsafe git operations | Unintended code from upstream |
| T8 | Supply chain via curl installer | Arbitrary code execution |
| T9 | Symlink confusion | Overwriting files not managed by bem |
| Threat | Reason |
|---|---|
| Malicious plugin code | bem is a manager, not a sandbox. Plugins run with full user permissions. Only install scripts you trust. |
| Root-level attackers | A root attacker can modify any file. No userspace tool can defend against this. |
| Compromised git remote | If an upstream repo is compromised, git pull will fetch malicious code. This is inherent to git-based distribution. Use signed commits and review diffs. |
Decision: symlinks. This is the most consequential security choice in bem.
An alias like alias myplugin='/path/to/script' is shell code that runs through bash's parser. If the path contains single quotes, backticks, $(), or \, the alias can become a code injection vector. Escaping all edge cases correctly is error-prone and has been a real-world vulnerability source in similar tools.
A symlink is a filesystem pointer resolved by the kernel. There is no string parsing, no quoting, no expansion. A path containing any combination of special characters works identically. The only failure mode — a broken symlink — is safe (command not found).
Additionally, symlinks eliminate the need to source plugin-related code in .bashrc. The previous alias-based design sourced one alias per plugin in every new shell. With symlinks, the sourced file is a fixed ~2KB autocomplete function regardless of how many plugins are installed. This minimizes the attack surface of code that runs on every shell startup.
Plugin names are validated against ^[a-zA-Z0-9_][a-zA-Z0-9._-]*$ with a maximum length of 64 characters. This prevents:
- Path traversal: names cannot start with
.or contain/, blocking..,.hidden, and/etc/evil. - Shell metacharacters: spaces, quotes, backticks,
$,*,?,|,;,&,(,)are all rejected. - Flag confusion: names cannot start with
-, so they can't be confused with command-line options.
Reserved name blocklist. Over 100 common system commands (ls, cat, sudo, git, python, vim, etc.) are blocked as plugin names. This prevents both accidental shadowing and deliberate hijacking of critical commands.
File paths are resolved through realpath and validated as regular, readable files. This rejects directories, device nodes, named pipes, and non-existent paths.
A plugin whose script file is world-writable is a security vulnerability: any local user can modify it, and it will be executed by the plugin owner.
bem detects world-writable files at two points:
- At install time: a visible warning is shown.
- In
bem status: the file is flagged asWORLD-WRITABLE.
This doesn't block installation (the user may have a reason), but ensures the risk is visible.
bem runs with umask 077. All files in ~/.bem/ are created with owner-only permissions:
- Directories:
700 - Files (metadata):
600 - Source file:
444(read-only after generation)
This prevents other users on a shared system from reading or modifying plugin metadata.
All write operations (install, remove, purge, enable, disable) acquire an exclusive lock on ~/.bem/.lock using flock(2). This prevents two concurrent bem invocations from:
- Creating conflicting symlinks.
- Partially removing a plugin while another operation reads its state.
- Writing to the same metadata files simultaneously.
If a lock is held, the second invocation fails immediately with a clear error message rather than waiting indefinitely.
The autocomplete source file is written using a temp file + mv pattern:
mktemp -> write to tmpfile -> chmod 444 -> mv tmpfile to final path
mv within the same filesystem is an atomic operation (single rename(2) syscall). This ensures:
- No half-written file if the process is killed mid-write.
- Other shells cannot source a partially written file.
- If
mktemporchmodfails, the old file remains intact.
- No overwrite of non-symlinks.
_create_linkrefuses to replace anything that is not a symlink. If~/.local/bin/searchis a real binary, bem will not destroy it. - Targeted removal.
_remove_linkonly deletes symlinks. Regular files are left alone with a warning. - Stale cleanup.
_sync_linksdetects and removes broken symlinks, but only for plugins that exist in the bem registry. - Uninstall isolation.
cmd_uninstallremoves only symlinks that correspond to registered plugins, then thebemsymlink itself. It does notrm ~/.local/bin/*.
All destructive operations require interactive confirmation with no global force flag:
| Command | Behavior | Confirmation |
|---|---|---|
remove |
Unregister from bem, keep original files | One per plugin |
purge |
Unregister AND delete original files | One per plugin (with warning) |
uninstall |
Remove bem, all data, all symlinks, bashrc entries | Double confirmation |
There is no -f or --force flag on any destructive command. This is deliberate.
bem wraps its .bashrc entries in block markers:
# bem:start
export PATH="${HOME}/.local/bin:${PATH}"
source '/home/user/.bem/bem_source.bash'
# bem:endOn uninstall, the entire block is removed with sed '/^# bem:start$/,/^# bem:end$/d'. This is safe because:
- The markers are hardcoded strings, not user input — no sed injection.
- The pattern is anchored to full lines (
^...$), preventing partial matches. - Everything outside the block is preserved exactly.
git pulluses--ff-onlyeverywhere. This refuses to create merge commits, preventing an attacker from exploiting merge conflict resolution to inject code.git fetch --quietinupgradeableonly downloads refs, never modifies the working tree. It is safe to run at any time.- Git repo detection uses
git -C <dir> rev-parse --is-inside-work-tree, which does not modify state.
The curl-based installer (install.sh) follows these practices:
- Inspectable. Users are encouraged to read the script before running it. The README shows both the pipe-to-bash method and the inspect-first method.
- Shallow clone. Uses
git clone --depth 1to minimize downloaded code. - No
sudo. The installer never requests or requires root privileges. - umask 077. Applied before any file creation.
- Fail-fast.
set -euo pipefailensures any error halts execution immediately rather than continuing in a broken state. - Explicit dependency check. Verifies
git,realpath,flock, and optionallyperlbefore proceeding.
Acknowledged risk: piping curl output to bash is inherently risky. The script could be modified server-side between inspection and execution. For maximum security, users should clone the repo manually and inspect the code.
The original version used sed -i "s|$SEARCH|$REPLACE|g". This was vulnerable to:
- Delimiter injection: if
SEARCHorREPLACEcontained|, sed'sscommand broke. - Regex injection:
.*,[,],+,\1in the search term would be interpreted as regex. - Replacement interpolation:
&,\1,\nin the replacement had special meaning in sed.
The rewrite uses perl's index() and substr() functions — pure string operations with zero pattern matching or interpolation. The search and replace strings are passed via $ENV{}, bypassing perl's argument parsing entirely.
Tested with: $100, @email, C:\path, .*regex[0], |pipe|, backticks, single quotes, double quotes, $(command).
All queries use grep -F (fixed strings). The original used find -name "*$QUERY*", where a query containing * or ? would be interpreted as glob patterns. The rewrite pipes filenames through grep -F, which treats the query as a literal substring.
Both plugins skip binary files and apply || true on all pipelines to prevent set -euo pipefail from causing silent crashes on permission errors.
| Version | Sourced at shell startup |
|---|---|
| Original (aliases) | ~50 bytes per plugin + autocomplete. Grows linearly. |
| Current (symlinks) | ~2KB fixed. Zero growth regardless of plugin count. |
The original read plugin paths with $(cat file), spawning a subshell + cat process for each read. The rewrite uses IFS= read -r var < file, which is a bash builtin — no fork, no exec.
Colors use hardcoded ANSI escape sequences instead of spawning tput (5 subshells eliminated per invocation).
Plugin invocation has zero overhead. A symlink is resolved by the kernel at exec() time — there is no shell wrapper, no function dispatch, no redirection. The performance is identical to running the script directly.
-
TOCTOU on install. Between
validate_file_pathandln -s, the file could theoretically be swapped. This requires an attacker with write access to the file's directory. Mitigation: install from trusted locations. -
No checksum verification. bem does not store or verify checksums of plugin files. A modified plugin file will be executed without warning.
bem statuschecks world-writable permissions but not content integrity. -
No sandboxing. Plugins execute with the full permissions of the invoking user. A malicious plugin can read, write, and delete any file the user owns.
-
Autocomplete runs in shell context. The
_bem_autocompletefunction iterates~/.bem/plugins/*/on every tab completion. This is safe (only reads directory names) but could be slow with thousands of plugins. -
Symlink targets are not locked. After installation, the original file can be moved, deleted, or modified without bem's knowledge.
bem statusdetects missing files; modification is not detected.
| Aspect | Original | Rewrite |
|---|---|---|
| Plugin mechanism | Alias in sourced file | Symlink in ~/.local/bin |
| Registry format | Fixed-width binary file | Directory-per-plugin filesystem |
| Modification method | sed -i on user input |
Atomic file creation, no sed |
| Input validation | None | Regex + reserved names + path traversal |
| Concurrency | None | flock |
| File permissions | Inherited | umask 077 |
| World-writable detection | None | Install + status |
| System command protection | None | 100+ reserved names |
| Sourced code | Grows per plugin | Fixed ~2KB |
| Destructive operations | Minimal confirmation | remove/purge/uninstall with mandatory confirmation |
| replace metacharacters | sed injection | perl index()/substr() |
| search query safety | Glob expansion | grep -F fixed strings |
| .bashrc management | Unstructured appended lines | Block markers (bem:start/end) |
- Review before install. Inspect
install.shandbembefore running them. This applies to any software, not just bem. - Don't install world-writable scripts. If bem warns about permissions, fix them.
- Use
bem statusregularly. It detects missing files, broken symlinks, and world-writable targets. - Review plugin updates. Before
bem update, consider runninggit -C <dir> log HEAD..origin/mainto see what changed. - Avoid
purgeunless certain.purgepermanently deletes original files. Useremoveto unregister without data loss.