fix: stabilization pass — fail-closed auth, ai flag validation, lint/config/security hardening, regression tests
gates / consistency-and-conventions (push) Successful in 26s
gates / consistency-and-conventions (push) Successful in 26s
17-point code-level audit executed via Explorer->Architect->Builder->Tester->Reviewer;
Reviewer accepted (APPROVE_WITH_NOTES; 3 block-list items resolved):
- security: telegram sender-owner AND-gate + TELEGRAM_OWNER_ID, matrix
MATRIX_ROOM_ID fail-closed, gpg --passphrase-fd 3 (no argv secret),
/dev/tcp positional-arg form (checkport/smb-client/share-lib/NET_PROBE),
eval deny-by-default + --no-command-execution carried by both chat bridges,
tty-gated --trust; config/{telegram,matrix}.env reference templates
- ai: all ExecStart flags validated against installed llama.cpp
(requested->error, default->omit+warn, CONFIG_REQUESTED_FLAGS); single-file
hf download failure rc=1 + no .hf-meta; LLAMACPP_HOST coherent;
POS_SUBCMDS + metadata gaps closed
- tooling: lint-conventions Bash-native rewrite (~24-30x faster, rules and
output byte-identical, :num restored); pos system uninstall covers all 12
libs + scale-tail + flags dir + systemd user units (|| true) + plugin
markers; anchored .bash_completion/.bashrc removal replaces sed -i '/pos/d'
- config: canonical load_env_file in lib/config-ui.sh (CRLF strip, env-wins,
XDG, LOADED_ENV_KEYS); 9 tools migrated; entertainment-lib collapsed to
wrappers; docker-compose deliberately unmigrated (source semantics)
- tests: first committed regression suite — tests/run-tests.sh zero-dep
runner + make test; 12 files / 179 checks / 0 skip / ~52s; hard skip
contract; systemd-analyze verify on generated unit PASS
Verified: make gen idempotent; make check green; make lint 0 FAIL, 0 WARN;
make test green; bash -n clean; git diff --check clean. Audit deliverables +
agent reports + AGENT_TODO Done entry included.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
# Security Audit — Command-Execution Surfaces, Chat Authorization, Secret Handling
|
||||
|
||||
**Date:** 2026-09-06
|
||||
**Explorer:** read-only investigation
|
||||
**Scope:** `bin/`, `lib/`, `scripts/`, `install.sh`, `preinstall.sh`, `postinstall.sh`, `apps/`, `entertainment/`
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Telegram listener skips SENDER authorization entirely.** It filters *only on chat id* (`TELEGRAM_CHAT_ID`), and even that filter is weakened by an OR-clause that also accepts `from_id`. No `from.username`/owner-user-id allowlist exists anywhere; there is no config key for one. Anyone who can get a message into the owner chat (shared chat, forwarded/mention, or a group where the bot sees the message with `from_id != chat_id`) can execute arbitrary mapped bash **and** the Gemini AI bridge as your user. This is the single highest-severity finding.
|
||||
- **Matrix listener DOES implement exactly "sender authorized AND chat authorized"**, but both are *optional-to-configure*: `MATRIX_USER_ID` defaults to a live `/whoami` resolution and `MATRIX_ROOM_ID` to "every room joined". If both are unset at runtime you get "any sender, any room" — remote code execution. Severity depends on which homeserver/rooms the account is in.
|
||||
- **AI command auto-execution (`pos ai` eval path)** is a real RCE primitive. Trusted aliases pass `--trust` (no confirmation). The listener's `<prefix>` bridge passes *unvalidated chat text* into a shell string that is executed by `bash -c` — a direct RCE.
|
||||
- **`/dev/tcp` probes interpolate unvalidated host/port into `bash -c` strings** in 4 places. Most reachable inputs are CLI args (interactive/low risk), but `pos-network-download` (`NET_PROBE`, network-derived) and the `share_port_probe` surface deserve review.
|
||||
- **Backup GPG passphrase** is passed on the gpg **command line** (`--passphrase "$PASS"`) → visible in `ps`/process args, and exported into any `notify`/log context via `set -x` if debugging. No temp files persist the secret; `mktemp` artifacts are cleaned on both success and failure, and the archive is `chmod 600`. The SMB client and Matrix login write credentials to throwaway/templated files correctly (`chmod 600`) and avoid argv exposure, but the SMB persistent credentials dir text is world-readable risk only via file perms (mode 600).
|
||||
|
||||
---
|
||||
|
||||
## 1. Command-Execution Inventory
|
||||
|
||||
All primitives below were located by grep across the specified paths; input provenance chain and classification are traced from source.
|
||||
|
||||
### `/dev/tcp` probes — host/port interpolated into `bash -c` string
|
||||
|
||||
| ID | file:line | primitive | input source | attacker-controlled? | classification |
|
||||
|----|-----------|-----------|--------------|----------------------|----------------|
|
||||
| C01 | `bin/pos-network-checkport:133` | `bash -c "exec 3<>/dev/tcp/$ip/$port"` | `ip` from CLI arg (`$host` from targets, validated no-spaces at :486), `port` validated `[0-9]{1,5}` 1-65535 | port no; host partially (no-space check only, IPv6 brackets stripped) | **REVIEW** — host could contain `;`/`$(...)` if crafted (only `*" "` is rejected; `;`, backticks, `$()` not blocked). Interactive CLI, but the string is unquoted. |
|
||||
| C02 | `bin/pos-network-checkport:157` | `bash -c "exec 3<>/dev/udp/$ip/$port; printf 'x' >&3"` | same provenance | same | **REVIEW** |
|
||||
| C03 | `bin/pos-network-checkport:166` | `bash -c ".../dev/tcp/$ip/$port; printf 'HEAD...'"` | same | same | **REVIEW** |
|
||||
| C04 | `bin/pos-network-checkport:168` | `bash -c ".../dev/tcp/$ip/$port; head -c 200"` | same | same | **REVIEW** |
|
||||
| C05 | `bin/pos-share-smb-client:92` | `timeout 3 bash -c "exec 3<>/dev/tcp/${host}/${SMB_PORT}"` | `host` from `//server/share` CLI arg; `split_share` extracts `SERVER` with no validation | **partially** — no filtering of `;`/`$()`/backticks in SERVER | **VULNERABLE-INPUT** (low exploitability: requires the operator to type such a host; but a malicious remote `//<payload>/share` string reaches shell). |
|
||||
| C06 | `lib/share-lib.sh:59` | `timeout 3 bash -c "exec 3<>/dev/tcp/${1}/${2}"` | `$1`=host, `$2`=port from callers (nfs/smb client mounts) | host unvalidated | **VULNERABLE-INPUT** (shared helper; called from CLI-only paths). |
|
||||
| C07 | `bin/pos-network-download:28,377` | `NET_PROBE=...bash -c '</dev/tcp/8.8.8.8/53>'`; `net_up() { bash -c "$NET_PROBE" }` | **controlled by `NET_PROBE` env var** | **yes** if `NET_PROBE` is attacker-set (e.g. via schedule/daemon env) | **REVIEW** — env-derived command string evaluated; DEFAULT is constant/safe. |
|
||||
|
||||
### Listener command execution (chat-driven)
|
||||
|
||||
| ID | file:line | primitive | input source | attacker-controlled? | classification |
|
||||
|----|-----------|-----------|--------------|----------------------|----------------|
|
||||
| C08 | `bin/pos-communication-telegram-listener:349` | `timeout "$tmo" bash -c "$cmdline"` (in `run_and_reply`) | `$cmdline` = map value (constant, owner-edited) **or** prefix bridge `$cmd $qtext` where `$qtext` = `printf '%q'` of chat text | `$qtext` is the chat text, but `%q`-quoted (safe metachar-wise) | **REVIEW** — map value is constant; prefix path quotes the arg. Map value itself is operator-owned. |
|
||||
| C09 | `bin/pos-communication-telegram-listener:697` | `run_and_reply "$cmd $qtext" ...` | prefix bridge: `$cmd` = map value (operator), `$qtext` = `%q` quoted chat text | chat text via `%q` (safe) | **REVIEW/SAFE** — quoting present but the whole string is one `bash -c`; the chat text is not the only component. |
|
||||
| C10 | `bin/pos-communication-matrix-listener:493` | `timeout 60 bash -c "$value"` | `$value` = map value (operator-owned, /cmd) | no (constant operator string) | **SAFE** (constant owner map). |
|
||||
| C11 | `bin/pos-communication-matrix-listener:214` | `timeout 60 bash -c "$value"` (ui test) | operator input in TUI | no | **SAFE/DESIGNED-INTERACTIVE**. |
|
||||
| C12 | `bin/pos-communication-telegram-listener:368` | `timeout 60 bash -c "$value"` (ui test) | operator input in TUI | no | **SAFE/DESIGNED-INTERACTIVE**. |
|
||||
|
||||
### AI command eval (RCE primitive)
|
||||
|
||||
| ID | file:line | primitive | input source | attacker-controlled? | classification |
|
||||
|----|-----------|-----------|--------------|----------------------|----------------|
|
||||
| C13 | `bin/pos-ai:388,403` | `run eval "$cmd"` (in `_prompt_run_command`) | `$cmd` = `_extract_commands` of the **AI provider's raw response** (gemini/openrouter/llamacpp output) | **yes — AI-generated** | **VULNERABLE-INPUT** (eval of untrusted model output). Trusted mode (`--trust`, `TRUST_MODE=1`) at :385-388 auto-executes with no prompt; non-trusted prompts on tty. Called from `cmd_ask:535` and `cmd_chat:572`. |
|
||||
| C14 | `bin/pos-communication-telegram-listener:734` | `timeout 120 pos ai gemini ask ... "$prompt"` | AI bridge: `$prompt` = chat text after `<prefix> ` | chat text — but only as an **argument** to `pos ai gemini ask` (a subprocess arg, quoted via `"$prompt"`); the AI eval inside pos-ai then acts on the model's *reply*. | **REVIEW** — the bridge feeds arbitrary chat text to the model; if the model echoes back a command, C13 fires. Indirect injection. |
|
||||
| C15 | `bin/pos-communication-matrix-listener:472` | `pos ai gemini ask ... "$prompt"` | same AI bridge (Matrix `ai ` prefix) | same | **REVIEW** (indirect). |
|
||||
|
||||
### `sudo` / `systemctl` / docker / ssh / curl|sh
|
||||
|
||||
- **`apps/**/*.sh`** — `sudo` used for `apt install`/`systemctl`/`usermod`/`curl | sh` installers. These run **once interactively at install time**; inputs are (mostly) constant URLs. Classified **SAFE/DESIGNED-INTERACTIVE**. Notable pipe-to-shell:
|
||||
- `apps/networking/zerotier.sh:9` — `curl -s https://install.zerotier.com | sudo bash` (network→shell, no verification). **Review**.
|
||||
- `apps/networking/netbird.sh:9`, `tailscale.sh:9` — `curl -fsSL ... | sh` (official vendor identical). **Review** (no checksum; standard vendor practice).
|
||||
- `apps/system/docker.sh:9` — `curl -fsSL https://get.docker.com | sh`. **Review**.
|
||||
- `apps/development/opencode.sh:9` — `curl -fsSL https://opencode.ai/install | bash`. **Review**.
|
||||
- `apps/utilities/tsui.sh:9` — `curl -fsSL https://neuralink.com/tsui/install.sh | bash`. **Review** (vendor unknown-ish host).
|
||||
- **`preinstall.sh:45,48,58,65` / `postinstall.sh:101-160` / `install.sh:134-239`** — `sudo apt`, `sudo systemctl`, `sudo install`, `git clone`. Constant/static targets. **SAFE** (interactive bootstrap).
|
||||
- **`bin/pos-network-scan:88,99`** — `sudo -n nmap` / `sudo nmap`; constant. **SAFE**.
|
||||
- **`bin/pos-network-hotspot:60-86`** — `sudo create_ap` with `"$@"` passthrough. CLI args reach sudo. **REVIEW** (interactive tool; operator supplies args).
|
||||
- **`bin/pos-system-backup:173`** — `sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"`; `$FOLDER` CLI arg baked into `$ARCHIVE` name. **REVIEW** (folder name into tar; low risk, arbitrary path backup).
|
||||
- **`bin/pos-share-nfs-client/smb-client`, `lib/usb-lib.sh`, `lib/share-lib.sh`** — `sudo mount`/`umount`/`mkdir` with mount paths; mountpoint paths validated to be absolute and non-system (smb-client `ask_new_mountpoint`/`menu_ask_mountpoint:579,614` deny `/etc,/boot,/bin,...`). **SAFE/REVIEW** — some paths from `findmnt`/menu.
|
||||
- **`bin/pos-docker-vbox:1074,1094,1096`** — `docker exec -it ... bash` (interactive attach). **SAFE/DESIGNED-INTERACTIVE**.
|
||||
- **`scripts/lint-conventions.sh`, `make gen` pipeline** — dev-time `bash -n`/`awk`/`sed`. Out of runtime scope (dev tooling, constant). **SAFE**.
|
||||
- **No `sshpass`, `scp`, or `ssh` remote-command execution** anywhere (only `pos ssh load-keys` adding keys to the agent, and doc references). No remote-shell-over-ssh primitive found. **N/A**.
|
||||
- **`lib/scheduler-lib.sh:180`** — `bash -c "$JOB_COMMAND"`; `JOB_COMMAND` is the literal remainder of a `schedule.d/*.env` line, operator-authored, syntax-checked (POS tool). **SAFE/DESIGNED-INTERACTIVE** (operator-owned config; would be RCE if an attacker could write `schedule.d/`).
|
||||
|
||||
### Secret-in-process-argv (gpg/openssl)
|
||||
|
||||
- **`bin/pos-system-backup:195`** — `gpg --passphrase "$PASS" --symmetric ...`: the backup passphrase is placed on the gpg **command line**, visible to any local user/process via `/proc/<pid>/cmdline` and to `ps as`. **See Section 3.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Authorization Model — Telegram + Matrix
|
||||
|
||||
### Telegram listener (`bin/pos-communication-telegram-listener`)
|
||||
|
||||
**Config keys read (via `load_config` line 86-99, plus direct grep in `ai_bridge_prefix`:625):**
|
||||
| Key | Defined in | Purpose in listener |
|
||||
|-----|-----------|---------------------|
|
||||
| `TELEGRAM_BOT_TOKEN` | `# POS_CONFIG:` in `telegram-sender:6`; `telegram.env` | authenticate Bot API |
|
||||
| `TELEGRAM_CHAT_ID` | same | **the only owner filter** (see below) |
|
||||
| `TELEGRAM_AI_PREFIX` | same | AI bridge trigger word (default `ai`) |
|
||||
|
||||
There is **no** `TELEGRAM_OWNER`, `TELEGRAM_USER_ID`, `TELEGRAM_ALLOWED`, `OWNER_ID`, or any sender-identity allowlist key anywhere in the codebase, config templates, or docs.
|
||||
|
||||
**Message flow & the update loop (lines 766-792):**
|
||||
1. `getUpdates` with `allowed_updates=["message"]` (line 771) — filters update *type* to messages only.
|
||||
2. Per update: extracts `chat` (`.message.chat.id`), `from_id` (`.message.from.id`), `text` (line 780-783).
|
||||
3. **The ONLY authorization gate is line 787:**
|
||||
```
|
||||
if [ -n "$chat" ] && [ "$chat" != "$TELEGRAM_CHAT_ID" ] && [ "$from_id" != "$TELEGRAM_CHAT_ID" ]; then continue; fi
|
||||
```
|
||||
This is: **continue (drop) ONLY IF** chat is set AND chat ≠ owner-chat **AND** from_id ≠ owner-chat.
|
||||
4. `handle_message` (line 674) then dispatches with **no further sender check**: `/command` map (via `map_get`, line 743) → `run_and_reply` → `bash -c` (C08); web-URL detection → `pos media grab` (line 704); prefix bridge (line 697) → `bash -c`; AI bridge (line 734) → `pos ai gemini ask`.
|
||||
|
||||
**Verdict — Telegram does NOT implement "sender authorized AND chat authorized":**
|
||||
- The check is **chat-or-sender OR**, not AND, and the "sender" comparison uses **`from_id` == `TELEGRAM_CHAT_ID`** — i.e. it assumes the owner's Telegram *user id* numerically equals the *chat id*. That is only true for a direct (1:1) private chat with the owner. In a **group or supergroup**, the `chat.id` is negative and differs from any `from.id`; the OR-clause then accepts **any** `from_id` that happens to equal `TELEGRAM_CHAT_ID` (unlikely) OR requires chat==owner. In a **shared/followup private chat** scenarios or when `TELEGRAM_CHAT_ID` is a forwarded context, the model breaks.
|
||||
- **Concretely exploitable:** if the bot's token is added to a group, `chat.id` (group, negative) ≠ `TELEGRAM_CHAT_ID` AND `from_id` (a member) ≠ `TELEGRAM_CHAT_ID` → condition **false** → message is dropped. So plain group members are blocked **only if** `TELEGRAM_CHAT_ID` is truly the owner's 1:1 chat and the bot isn't also filtering otherwise. BUT there is **no sender allowlist**, so the guard is the sole mechanism and it mis-handles the general case. If the owner ever sets `TELEGRAM_CHAT_ID` to a group id (a plausible misconfiguration the tool doesn't prevent), **anyone in that group executes commands**. Also the model treats `from_id == TELEGRAM_CHAT_ID` as allowed even when `chat` is different — so a message where sender id coincidentally equals the configured numeric id (or a bot re-post) is accepted regardless of chat.
|
||||
- **No `from.username`, no `from.first_name/last_name` allowlist, no user-id allowlist** — the requirement "is the SENDER's from.id checked ANYWHERE before a mapped command or AI bridge executes?" is answered: **yes, but only against the chat-id value via an OR with chat-id, and only for one numeric field.** This is not a proper sender authorization.
|
||||
- **Missing/invalid config behavior:** `run_daemon:759-760` `err`s (exits) if token or chat-id are unset. Good fail-closed for *chat* but there is no sender config to miss.
|
||||
|
||||
**Map-file editing surface:** exclusively the **interactive TUI** (`ui()` at line 440, no-arg invocation; `ui_add/ui_edit/ui_remove/ui_test`, lines 378-438) and the `prefix` verb for the prefix map (`prefix_cmd:567`). No network/API path writes `telegram_commands.env`. The map file is `chmod 600` (`map_set:137,144`). **Risk:** it is a **local** file `~/.config/linux_post_install/telegram_commands.env` editable only by the owner at the shell; if the owner account is compromised the map is trivially editable (but that's full-host compromise anyway). The `/cmd::description=` text (user-typed description) flows into `sync_bot_commands` → `setMyCommands` (line 215), so a map *description* is sent to Telegram — a low-level info leak of the operator's own design, not an attacker surface.
|
||||
|
||||
### Matrix listener (`bin/pos-communication-matrix-listener`)
|
||||
|
||||
**Config keys read (via `load_config`:65-78):**
|
||||
| Key | Defined in | Purpose |
|
||||
|-----|-----------|---------|
|
||||
| `MATRIX_HOMESERVER` | `# POS_CONFIG:` `matrix-sender:5`; `matrix.env` | server URL for /sync |
|
||||
| `MATRIX_ACCESS_TOKEN` | same | auth |
|
||||
| `MATRIX_USER_ID` | same | **owner/sender filter** |
|
||||
| `MATRIX_ROOM_ID` | same | **room filter** (optional) |
|
||||
|
||||
**Message flow & /sync loop (lines 509-557):**
|
||||
1. Resolves owner: `MATRIX_USER_ID` if set, else live `/account/whoami` (lines 514-523).
|
||||
2. `room_only="${MATRIX_ROOM_ID:-}"` (line 515) — **empty = watch every join.**
|
||||
3. Per room: skip if `room_only` set and `room != room_only` (line 539).
|
||||
4. Per event: require `type==m.room.message`, `content.msgtype==m.text` (546-547); **sender filter line 550:** `[ "$sender" = "$owner" ] || continue` — message REFUSED unless the sender is the owner.
|
||||
5. `handle_message` (line 447) dispatches with **no further sender check**: `/`/`!` command map (line 483) → `bash -c` (C10); `ai ` bridge (line 472) → `pos ai gemini ask`.
|
||||
|
||||
**Verdict — Matrix:**
|
||||
- `MATRIX_ROOM_ID` set: **sender authorized (== owner) AND chat authorized (== configured room)** — matches the requirement. ✅
|
||||
- `MATRIX_ROOM_ID` unset but `MATRIX_USER_ID` set: sender authorized, chat = **every joined room** — chat is NOT pinned. An owner tweet from any room triggers RCE. ⚠️
|
||||
- Both unset: owner auto-resolved via whoami (still "sender == owner"), room = every room. Still sender-gated, but the room is unbounded. If `whoami` *fails* (line 520-521), it `err`s out (fail-closed). So Matrix is **sender-gated always** (owner == account's own user id), chat optionally restricted. This is a materially **stronger and correct** model than Telegram's.
|
||||
|
||||
**Guard lines that would need to change to satisfy "user AND chat authorized" fully:**
|
||||
- **Telegram:** replace the OR at `bin/pos-communication-telegram-listener:787` with an AND requiring `chat == TELEGRAM_CHAT_ID` **and** a sender check against a new allowlist key (e.g. `TELEGRAM_OWNER_ID`). Minimal sketch:
|
||||
```bash
|
||||
# 1) Chat must be the owner chat
|
||||
[ "$chat" = "$TELEGRAM_CHAT_ID" ] || continue
|
||||
# 2) Sender must be an allowed user id (new key, fail-closed if unset)
|
||||
[ -n "${TELEGRAM_OWNER_ID:-}" ] || { warn "no TELEGRAM_OWNER_ID — refusing"; continue; }
|
||||
case " $TELEGRAM_OWNER_ID " in *" $from_id "*) ;; *) continue ;; esac
|
||||
```
|
||||
plus add the `TELEGRAM_OWNER_ID` key to the `# POS_CONFIG:` registry in `telegram-sender:6` (`pos config telegram`). **Do NOT implement — this is exploration output only.**
|
||||
- **Matrix:** to fully pin chat even when `MATRIX_ROOM_ID` is unset, `run_daemon` should fail-closed (refuse to start) or require a room list; currently line 515 defaults to "watch all." Add validation that `MATRIX_ROOM_ID` is set before `--run` (or an explicit allowlist). **Do NOT implement.**
|
||||
|
||||
### Other listener/plugin paths that execute commands
|
||||
- **Entertainment plugins** (`entertainment/{weather,joke,gold}.sh`) fetch public APIs and print text — **no command execution**; output is sent via `pos-entertainment-send` → `notify_send`. Not an RCE surface via chat; they run on a schedule or explicit `pos entertainment send`.
|
||||
- **`pos-docker-vbox enter`** (`docker exec -it bash`) is operator-attach only.
|
||||
- **No other chat→command executor** found besides the two listeners and the AI bridge.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backup / GPG Secret Handling
|
||||
|
||||
File: `bin/pos-system-backup`. **PASS provenance chain:**
|
||||
1. **Prompted interactively** (lines 183-186): `read -s -rp "Enter backup password:" PASS`, then `read -s -rp "Confirm..." CONFIRM`. Read from the terminal with echo suppressed — **not** from config file, env, or argv. Good.
|
||||
2. Validation (187-191): non-empty and `PASS == CONFIRM`, else loops.
|
||||
3. `unset CONFIRM` (192) immediately after.
|
||||
4. **Every place PASS is used:**
|
||||
- line 195: `gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"` — **PASS on the command line** → visible in `/proc/<pid>/cmdline` and `ps` output of `gpg`. **This is the exposure.**
|
||||
- line 202: `gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf -` — verify/decrypt path, **same argv exposure**.
|
||||
- line 204: `unset PASS`.
|
||||
5. **Temp files:** none created for the secret. `mktemp` is **not** used anywhere in backup; the archive is `tar -czvf "$ARCHIVE"` (line 173), then encrypted, then `rm -f "$ARCHIVE"` (line 197) leaving `$ARCHIVE.gpg`, `chmod 600` (line 199). The plain intermediate `.tar.gz` is removed **after** encryption. On **failure** (encrypt/verify fail → `set -euo pipefail` aborts): if gpg fails at 195, the plain `$ARCHIVE.tar.gz` **remains on disk** (sleeped only after the successful encrypt at 197). No trap removes the plain intermediate. This is a **partial-failure plaintext-leak** risk (archive stays unencrypted if `gpg --symmetric` fails). Also the top trap (line 17) sends `notify_send "Backup FAILED..."` on any ERR — does not leak PASS but does announce paths.
|
||||
6. **verify/decrypt path:** line 202 pipes gpg decrypt into `tar -tzf -` and discards (no plaintext written to disk) — good, verify is streaming.
|
||||
7. **notify/msg exposure:** backup success sends `notify_send "Backup completed: $ARCHIVE"` (line 211) and USB copy sends `...: $dest` (line 112) — **artifact paths (not contents) are sent to Telegram/Matrix** via `lib/notify.sh`. These are local absolute paths; harmless unless they reveal structure the owner wants private. The **gpg passphrase is NOT** in any notify message.
|
||||
8. **Shell-history/log exposure:** PASS is read via `read -s` (not echo'd, not in history). It appears only in the gpg argv; not logged to any file by `pos`. However if the operator runs the tool under `set -x` or with shell tracing, `PASS` is a shell variable and would be expanded into stderr; not a repo bug, but the argv placement (not env) is the primary leak vector.
|
||||
|
||||
**Other secrets in process argv repo-wide:**
|
||||
- `bin/pos-network-hotspot:60` — `sudo create_ap ... "$@"`; if a passphrase/SSID is passed as an arg it enters `create_ap` argv. Interactive, no secret stored.
|
||||
- `matrix-sender` `login` (pos-communication-matrix-sender:167-190): password read via `read -rsp ... </dev/tty`, sent **in the HTTP JSON body** (not argv), access token saved to `matrix.env` `chmod 600` (save_config:67). **Good** — no argv leak.
|
||||
- `pos-share-smb-client` `make_creds` (143-152): password `read -rsp`, written to a throwaway `mktemp` file `chmod 600`, `printf '...password=%s'` — **not argv**. SMB persistent creds `sudo install -m 600` to `/etc/samba/credentials/` (line 346). **Good**.
|
||||
- `pos-ai` API keys: sent as HTTP header `x-goog-api-key: ${AI_API_KEY}` (gemini.sh:25) — **not argv**. `AI_API_KEY`/`AI_GEMINI_API_KEY` exported env var; safe.
|
||||
- Telegram bot token: used in URL `.../bot${TELEGRAM_BOT_TOKEN}/...` (listener:636,768; sender:81) — appears in **URLs/argv of curl** (`curl ... https://api.telegram.org/bot<TOKEN>/...`). The token is in curl's argv → visible via `ps`. **This is a real secondary exposure**: the Telegram bot token is a local-process-argv secret. Same class as the gpg passphrase.
|
||||
|
||||
---
|
||||
|
||||
## Severity-ranked concrete vulnerabilities (real, not hypothetical)
|
||||
|
||||
**V1 — HIGH — Telegram listener lacks sender authorization; remote code execution as your user.**
|
||||
`bin/pos-communication-telegram-listener:787` is the only gate and it is an OR over `chat`/`from_id` against the single `TELEGRAM_CHAT_ID` value, with **no sender allowlist key existing anywhere**. Any message that satisfies either `chat == TELEGRAM_CHAT_ID` OR `from_id == TELEGRAM_CHAT_ID` triggers mapped `bash -c` (C08) and the Gemini bridge. In group/shared-chat misconfiguration, any member runs arbitrary commands as the owner. Even in the "correct" 1:1 setup there is no authenticated-sender binding, so a bot-repost or replayed `from_id` is accepted regardless of source chat. Evidence: lines 787, 743, 697, 734.
|
||||
|
||||
**V2 — HIGH — `pos ai` evaluates arbitrary AI-provider output; `--trust` removes confirmation.**
|
||||
`bin/pos-ai:388,403` runs `eval "$cmd"` where `$cmd` is extracted from the provider's raw response (`_extract_commands`:363). The Telegram/Matrix AI bridges (listener:734/:472 / C14/C15) feed arbitrary chat text as the prompt; if the model's reply (or a prompt-injection / model misbehavior) emits a fenced `bash`/`sh` block, it is executed. Non-trust mode prompts on a tty (`[ -w /dev/tty ]`), but **trusted aliases** pass `--trust` (`TRUST_MODE=1`, :385-388) → auto-execute, no confirm. And the **AI bridges run non-interactively (no tty)**, so `_prompt_run_command`'s `[ -w /dev/tty ] || return 0` at line 382 returns 0 **without prompting** → **any** command block in a bridge-AI reply executes automatically even without trust. This makes the chat AI bridge an unconditional RCE on model output. Evidence: pos-ai:382,388,403,535,572; listeners:734/:472.
|
||||
|
||||
**V3 — HIGH — backup GPG passphrase on command line (argv exposure).**
|
||||
`bin/pos-system-backup:195,202` pass `--passphrase "$PASS"` to gpg → secret readable by any local user or leak to syslog/ps. Evidence: lines 195, 202. (Also the plain `.tar.gz` can remain on a failed encrypt: line 197 is only reached after a successful 195.)
|
||||
|
||||
**V4 — MEDIUM — Matrix listener optional chat authorization.**
|
||||
When `MATRIX_ROOM_ID` is unset (default), `run_daemon:515` watches **all joined rooms**; sender still must equal the owner (`:550`), so it's owner-only but unbounded-room. If the account is in any shared room and the owner sends a command there, it executes. Fails closed on unresolved owner (`:521`), so severity is bounded to owner-initiated events. The authorization model is *correct* conceptually but chat-scope defaults too broadly.
|
||||
|
||||
**V5 — MEDIUM — unvalidated host/port interpolated into `bash -c /dev/tcp` strings.**
|
||||
`bin/pos-network-checkport:133,157,166,168`, `bin/pos-share-smb-client:92`, `lib/share-lib.sh:59`. Host strings are not fully validated (no `;`/`$()`/backtick reject) before being embedded in a shell string. Reachable via CLI args (interactive) and `NET_PROBE` env (`pos-network-download:28,377`).
|
||||
|
||||
**V6 — LOW/MEDIUM — `curl | sh` / `curl | sudo bash` installers without checksums.**
|
||||
`apps/{zerotier,netbird,tailscale,docker,opencode,tsui}.sh`. Vendor-standard, but supply-chain risk from the remote script. Interactive install-time only.
|
||||
|
||||
**V7 — LOW — Telegram bot token in curl argv.**
|
||||
`bin/pos-communication-telegram-listener:636,768` and `telegram-sender:81` place `bot<TOKEN>` in a URL passed to curl → token visible in `/proc/<pid>/cmdline`. Same class as V3.
|
||||
|
||||
---
|
||||
|
||||
## Uncertainties / Could-not-verify
|
||||
|
||||
- **Exact live behavior of the Telegram `from_id`/`chat_id` equality in real groups** cannot be established by read-only inspection; the numeric-equality assumption is documented in the code (line 787) but its breakage requires a live group test. This is the crux of V1's real-world exploitability and needs a live check by another agent.
|
||||
- **Whether `ps`/`/proc` argv is considered a real threat model** for this homelab (single-user local machine) is a policy/rationale question the Explorer can't decide — see note in V3.
|
||||
- **`pos-network-download`'s `NET_PROBE`** default is constant; whether any deployment injects an attacker-controlled value is unknown — it honours an env var seam.
|
||||
- **Whether any operator already sets a `MATRIX_ROOM_ID`** (affects V4) is config state not present in the repo (config files are gitignored).
|
||||
- The exact content of runtime `telegram_commands.env` / `matrix_commands.env` map files (what commands are mapped) is unknown — gitignored.
|
||||
|
||||
---
|
||||
|
||||
## Important Files
|
||||
- `bin/pos-communication-telegram-listener` — auth gate (787), dispatch, AI/prefix bridges.
|
||||
- `bin/pos-communication-matrix-listener` — owner filter (550), room gate (539).
|
||||
- `bin/pos-ai` — eval path (388,403,382), trusted mode, bridge call sites.
|
||||
- `bin/pos-system-backup` — passphrase argv usage (195,202).
|
||||
- `bin/pos-network-checkport`, `bin/pos-share-smb-client`, `lib/share-lib.sh`, `bin/pos-network-download` — /dev/tcp interpolation.
|
||||
- `lib/ai-providers/gemini.sh` — API key in HTTP header (not argv).
|
||||
- `bin/pos-communication-{telegram,matrix}-sender` — POS_CONFIG registry, token/creds handling.
|
||||
- `lib/notify.sh` — artifact-path-only notify sends.
|
||||
|
||||
---
|
||||
|
||||
## Handoff
|
||||
|
||||
- **Status:** COMPLETE (investigation objective satisfied; no code changed).
|
||||
- **Objective:** Security audit of command-execution surfaces, chat authorization, secret handling.
|
||||
- **Evidence:** file:line citations throughout; classification by certainty (SAFE / DESIGNED-INTERACTIVE / VULNERABLE-INPUT / REVIEW) with provenance chains.
|
||||
- **Affected areas:** Telegram & Matrix listeners, `pos-ai` eval, backup GPG, /dev/tcp probes, installer curl|sh.
|
||||
- **Scope/decision boundary:** Read-only exploration only; **no changes proposed for implementation.** Minimal change sketch for the Telegram auth gate is provided at Section 2 (as exploration output, explicitly NOT implemented).
|
||||
- **Verification performed:** Full source tracing of both listeners, pos-ai eval, backup, notify, and all grep'd primitives; git blame on the Telegram gate (unchanged since 2026-08-06, b9edd078 / 014d6be).
|
||||
- **Remaining uncertainty:** live Telegram group behavior (V1), NET_PROBE env deploy state, MATRIX_ROOM_ID config state, real `ps`-argv threat model.
|
||||
- **Recommended next agent:** **Architect** — the two listeners embody two different authorization philosophies (Telegram: chat-id-OR, no sender allowlist; Matrix: owner-sender with optional room). Aligning them into one "sender AND chat authorized" contract is a cross-component design decision (config schema + registry keys + both daemons), which is precisely an architectural boundary. Evidence above gives the exact guard lines and a minimal-change sketch to evaluate, not implement.
|
||||
|
||||
### Scope-expansion note
|
||||
Investigating Telegram/Matrix authorization surfaced that the auth contract is **not a single-file bug** but a **cross-component, decision-level** matter (two daemons, the `# POS_CONFIG:` registry, config templates, docs, and a new `TELEGRAM_OWNER_ID`-style key). That is an architectural decision, so the handoff above goes to **Architect** per the Explorer's scope rule.
|
||||
|
||||
```text
|
||||
Status: COMPLETE (with Architect handoff on scope expansion)
|
||||
Reason: Fixing chat authorization correctly spans two listeners + config schema + registry + docs
|
||||
Evidence: listener auth gates telegram:787 / matrix:550,539; POS_CONFIG headers in senders; no owner key anywhere
|
||||
Affected areas: bin/pos-communication-telegram-listener, matrix-listener, telegram-sender (POS_CONFIG), config templates, DOC
|
||||
Decision required: Architect
|
||||
Out-of-scope changes: none
|
||||
```
|
||||
Reference in New Issue
Block a user