diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 04ce916..e7489e2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "sshx", - "version": "0.19.0", + "version": "0.20.0", "description": "Agent-oriented remote host execution over SSH — JSON results, dry-run plans, safety rails, audit trail, MCP server, and an installable Agent skill.", "author": { "name": "talkincode", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 538538d..28c1959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - go: ["1.26.8"] + go: ["1.26.10"] steps: - name: Checkout code @@ -63,7 +63,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.8" + go-version: "1.26.10" - name: Cache Go modules uses: actions/cache@v4 @@ -112,7 +112,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.8" + go-version: "1.26.10" - name: Download dependencies run: go mod download @@ -138,11 +138,39 @@ jobs: security list-keychains -d user -s "$keychain_path" security default-keychain -d user -s "$keychain_path" + - name: Prepare disposable native sudo policies + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + for role in password nopasswd cached; do + sudo useradd -m -s /bin/sh "sshx-sudo-$role" + printf 'sshx-sudo-%s:operator-pass\n' "$role" | sudo chpasswd + done + sudo install -m 0440 /dev/stdin /etc/sudoers.d/sshx-e2e <<'SUDOERS' + Defaults:sshx-sudo-password timestamp_timeout=0 + Defaults:sshx-sudo-cached timestamp_type=global,timestamp_timeout=5 + sshx-sudo-password ALL=(ALL) PASSWD: ALL + sshx-sudo-cached ALL=(ALL) PASSWD: ALL + sshx-sudo-nopasswd ALL=(ALL) NOPASSWD: ALL + SUDOERS + sudo visudo -cf /etc/sudoers.d/sshx-e2e + - name: Run compiled-binary E2E tests env: SSHX_E2E_REAL_KEYRING: ${{ runner.os == 'macOS' && '1' || '0' }} + SSHX_E2E_NATIVE_SUDO: ${{ runner.os == 'Linux' && '1' || '0' }} run: go test -v -count=1 ./tests/e2e + - name: Remove disposable native sudo policies + if: always() && runner.os == 'Linux' + shell: bash + run: | + sudo rm -f /etc/sudoers.d/sshx-e2e + for role in password nopasswd cached; do + sudo userdel -r "sshx-sudo-$role" || true + done + - name: Remove isolated macOS keychain if: always() && runner.os == 'macOS' shell: bash @@ -197,7 +225,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.8" + go-version: "1.26.10" - name: Verify native SQL prerequisites # ubuntu-latest includes native clients. Missing prerequisites must fail @@ -246,7 +274,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.8" + go-version: "1.26.10" - name: Run golangci-lint uses: golangci/golangci-lint-action@v7 @@ -267,12 +295,12 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.8" + go-version: "1.26.10" - name: Run Go vulnerability scanner uses: golang/govulncheck-action@v1 with: - go-version-input: "1.26.8" + go-version-input: "1.26.10" go-package: ./... repo-checkout: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc0cf99..3c638bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.8" + go-version: "1.26.10" - name: Get version id: get_version diff --git a/AGENT.md b/AGENT.md index afa3aa2..3e9feb0 100644 --- a/AGENT.md +++ b/AGENT.md @@ -4,7 +4,7 @@ Operating guide for humans and AI coding agents working on **sshx**. It defines what this project is, what it deliberately is *not*, how it is built, and how to make changes that fit. Read this before making non-trivial changes. -Module: `github.com/talkincode/sshx` · Language: Go 1.26.8 · License: MIT +Module: `github.com/talkincode/sshx` · Language: Go 1.26.10 · License: MIT --- @@ -140,6 +140,8 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman sql.go → sshx sql: guarded SQL pipeline (classify → gate → explain → backup → execute) apply.go → sshx apply: guarded single-file mutation (hash → backup → atomic write) text.go → sshx text: bounded remote text/log dissection + ros.go → RouterOS commands/workflows; shared transport and file evidence + ros_lifecycle.go → ROS operation metadata and shared execution/file adapters login.go → sshx login: human TTY session, optional sudo privileged shell mcp.go → sshx mcp: stdio MCP server; tools self-exec sshx as one-shot children internal/execution/ → versioned request/result model, selectors, executor @@ -149,6 +151,7 @@ internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution internal/skillinstall/ → conflict-safe, atomic Agent skill installation internal/sqlsafe/ → fail-closed SQL classification, policy gates, transactional backup decisions, psql/sqlite3 assembly internal/textsafe/ → lexical presets, exception blocks, redaction, bounded text scan +internal/ros/ → RouterOS parsing/encoding and diagnostics (raw bytes remain raw) internal/sshclient/ → SSH/SFTP core client.go → SSHClient: dial, auth, exec, SFTP, sudo-over-stdin remote_state.go → restrictive atomic remote observation I/O @@ -210,7 +213,7 @@ skills/ → canonical Agent skill plus its embedded asset packa ## 5. Tech Stack -- **Language:** Go (module directive pinned to **`go 1.26.8`** — see constraint below). +- **Language:** Go (module directive pinned to **`go 1.26.10`** — see constraint below). - **SSH/crypto:** `golang.org/x/crypto/ssh` - **SFTP:** `github.com/pkg/sftp` - **Keyring:** `github.com/zalando/go-keyring` @@ -219,10 +222,13 @@ skills/ → canonical Agent skill plus its embedded asset packa > Security baseline reviewed for v0.14.0: x/crypto v0.56.0 fixes reachable > SSH channel deadlocks GO-2026-6354 and GO-2026-6355 and requires Go 1.26. -> Go 1.26.8 is the patched build baseline; CI and releases use the same version. +> The v0.20 security review advances the patched baseline to Go 1.26.10: +> govulncheck found reachable standard-library issues in 1.26.8, including +> GO-2026-6809 (fixed in 1.26.10) and GO-2026-66xx (fixed in 1.26.9). +> CI and release artifacts use the same reviewed patch baseline. > -> ⚠️ **Toolchain constraint:** CI's test/lint/security jobs run on **Go 1.26.8**. -> The `go` directive in `go.mod` must stay at `1.26.8` unless a deliberate +> ⚠️ **Toolchain constraint:** CI's test/lint/security jobs run on **Go 1.26.10**. +> The `go` directive in `go.mod` must stay at `1.26.10` unless a deliberate > security or compatibility review changes the baseline. New dependencies must > support that toolchain; do not let `go get` silently bump the directive. @@ -260,7 +266,7 @@ Notes: ### CI (`.github/workflows/`) -- `ci.yml`: **Test** (ubuntu + macOS, Go 1.26.8, `-race -cover`), +- `ci.yml`: **Test** (ubuntu + macOS, Go 1.26.10, `-race -cover`), **Test (windows-latest)** (`-short`, build + vet), **Lint** (golangci-lint), **Security Scan** (`gosec` plus `govulncheck`), **Analyze** (CodeQL, Go), **E2E** (ubuntu + macOS), and **SQL (native PostgreSQL and MySQL)** with @@ -278,7 +284,7 @@ Notes: Windows Credential Manager needs an isolated OS profile and is not proved by the test-only secret backend. Native CI results are required; a Windows cross-build on macOS/Linux is not execution evidence. -- `release.yml`: builds release artifacts with Go 1.26.8 and bundles the matching +- `release.yml`: builds release artifacts with Go 1.26.10 and bundles the matching Agent skill in every archive. All `ci.yml` checks must be green before merge. @@ -306,12 +312,16 @@ tests. or in an explicit encrypted local vault. Inline `--password-set=key:value` and `SSH_PASSWORD` are supported but warned about. Never silently degrade the backend, and never emit vault secrets on `--password-get`. -3. **Sudo password over stdin.** Never interpolate the password into the command - string. `sudoStdinCommand` rewrites a leading `sudo` to `sudo -S -p ''` and the - password is fed via `session.Stdin`. This avoids quote breakage and injection. +3. **Sudo conversation is separate from application stdin.** Never interpolate + credentials into argv/env or unconditionally prefix them to a script/SQL + stream. `sudoExchange` sends a credential only on a nonce-bound prompt and + payload only after child readiness; NOPASSWD/cache/root must consume no + credential. Test both branches with actual shells and disposable native sudo. + Human `login --sudo` leaves authentication to the TTY; it never prefills a secret. 4. **Sudo auto-fill only supports leading `sudo`.** `CommandUsesSudo` returns - true only when the remote command starts with `sudo`, matching the exact - form `sudoStdinCommand` can safely rewrite. Non-leading sudo inside shell + true only when the remote command starts with `sudo`. The readiness wrapper + preserves argv and outer shell operators; ambiguous sudo login/shell modes + fail closed. A restricted command-only sudoers grant may reject the wrapper. Non-leading sudo inside shell wrappers or pipelines is left untouched and is never silently ignored: sshx warns on stderr before connecting and again when the remote reports `sudo: a password is required`, suggesting `sudo sh -c ""`. @@ -513,9 +523,9 @@ When working in this repo: 1. **Stay within the mission.** Re-read §3 before adding features. Default to a smaller change. Never introduce a daemon, a connection pool, an HTTP/SSE protocol server, tunneling, or a GUI. -2. **Hold the toolchain line.** Keep `go.mod` at `go 1.26.8`. If a dependency +2. **Hold the toolchain line.** Keep `go.mod` at `go 1.26.10`. If a dependency forces a newer directive, pin an older compatible version instead of bumping - the directive (CI runs Go 1.26.8). + the directive (CI runs Go 1.26.10). 3. **Verify before declaring done.** Run `make check` (and `golangci-lint run`) locally; reproduce the original symptom and confirm it is gone. For PR work, watch CI to green (`gh pr checks --watch`). diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fb432..ca316ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2026-10-27 + +### Security + +- Update the reviewed Go build baseline to 1.26.10 across the module, CI and + releases, addressing newly reported reachable standard-library issues in + 1.26.8 (including GO-2026-6809). +- Separate noninteractive sudo authentication from script, SQL and other + application stdin using a nonce-bound prompt/readiness handshake. No + credential is sent when sudo does not request one. Authentication framing + is bounded and withheld from application output. +- Human `login --sudo` now uses native TTY authentication without reading or + prefilling the sudo keyring credential. +- Redact named token suffixes, authorization headers and escaped continuations + in audit records; label the policy explicitly as best-effort. +- RouterOS file workflows reuse guarded SFTP publication and downloads instead + of direct truncate/copy, preserving symlink protection and close errors. + +### Fixed + +- RouterOS raw commands preserve original quoting and option order. Recognized + device diagnostics fail even with SSH exit status zero; execution/audit + evidence, SSH credential references, jump hosts and deadlines stay aligned. +- RouterOS backup/export default filenames are unique per invocation. Script + updates do not retry a mutation after an uncertain transport result. +- Host diagnostics retain trust configuration and cancellation/deadline limits. +- Prefer already-known host-key algorithms and distinguish an unregistered + algorithm from a changed key of the same type, without relaxing verification. +- Restore bounded SFTP read/write pipelining while keeping hashes, publication + acknowledgement, cleanup and partial-effect evidence. + +### Added + +- `apply --sudo --stage-dir=PATH` (MCP `stage_dir`) selects a private SFTP + staging filesystem separately from backups and final atomic publication. +- Compiled-CLI regressions and disposable Linux native-sudo CI policies for + password, NOPASSWD, cached, root and rejected-authentication paths. + +### Changed + +- Plan schema remains v1; execution semantics advance to `sshx.execution.v2`. + Re-preview existing plans after upgrading. +- Sudo auto-fill uses a shell readiness wrapper. Restricted command-only + sudoers policies may reject it; ambiguous sudo login/shell options fail + closed. Use the human login path instead of bypassing policy. +- Raw RouterOS commands no longer add `without-paging` implicitly; include it + explicitly when needed. See `docs/execution-safety.md` for compatibility and + verification limits. + ## [0.19.1] - 2026-09-28 ### Fixed @@ -845,7 +894,8 @@ diagnostics). - CI/CD workflow and automated release process - Tag creation script -[Unreleased]: https://github.com/talkincode/sshx/compare/v0.19.1...HEAD +[Unreleased]: https://github.com/talkincode/sshx/compare/v0.20.0...HEAD +[0.20.0]: https://github.com/talkincode/sshx/compare/v0.19.1...v0.20.0 [0.19.1]: https://github.com/talkincode/sshx/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/talkincode/sshx/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/talkincode/sshx/compare/v0.17.0...v0.18.0 diff --git a/README.md b/README.md index 6a60a76..2d6d7e0 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,16 @@ It remains a single binary with one-shot invocations and no resident component o stderr scan progress so a wide window is never mistaken for a hang. 16. Agent skill (`skills/sshx/SKILL.md`): `npx skills add talkincode/sshx` or `sshx skill install`. +## Execution safety upgrade (v0.20) + +Sudo authentication and script/SQL input now use separate prompt/readiness +phases. `login --sudo` authenticates at the human TTY, without credential +prefill. RouterOS raw commands preserve their text, device errors fail clearly, +and file actions share guarded SFTP. Host tests honor deadlines; transfers use +bounded pipelining. `apply --sudo --stage-dir=/data/sshx-stage` supports a private +staging filesystem separate from backups. Re-preview plans after upgrading +(execution semantics v2). See [safety and compatibility notes](docs/execution-safety.md). + ## Installation ### Agent skill diff --git a/README_CN.md b/README_CN.md index be09b3d..bd90653 100644 --- a/README_CN.md +++ b/README_CN.md @@ -80,6 +80,15 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组 15. `sshx text`:有界远端日志/文本解剖(异常块、preset、行窗),经 SFTP 或 sshx 持有的 journalctl;读取走并发流水线,扫描进度写 stderr,宽窗口不再像卡死。 16. Agent skill(`skills/sshx/SKILL.md`):`npx skills add talkincode/sshx` 或 `sshx skill install`。 +## v0.20 执行安全升级 + +sudo 认证与脚本/SQL 输入改为独立的 prompt/ready 阶段,不再无条件拼接口令。 +`login --sudo` 改由人类 TTY 完成认证,不预填 keyring 密码。RouterOS raw 保留原文、 +设备错误明确失败,文件动作复用受控 SFTP;主机测试继承超时,传输使用有界流水线。 +`apply --sudo --stage-dir=/data/sshx-stage` 可指定独立私有暂存目录,备份目录与最终 +原子发布语义不变。升级后必须重新预览计划(execution semantics v2)。 +详情与兼容边界见 [执行安全说明](docs/execution-safety.md)。 + ## 安装 ### 安装 Agent skill diff --git a/SECURITY.md b/SECURITY.md index f80c85a..cff2645 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ We take security seriously. The following versions of SSHX are currently support | Version | Supported | | -------- | ------------------ | +| 0.20.x | :white_check_mark: | | 0.19.x | :white_check_mark: | -| 0.18.x | :white_check_mark: | -| < 0.18.0 | :x: | +| < 0.19.0 | :x: | Security updates are provided for the latest minor release and the previous minor release (N-1). Older lines do not receive patches; please upgrade. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e6acbc1..de14585 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -12,6 +12,7 @@ - [Audit Query and Export](audit.md) - [Contract Freeze Policy](contract.md) - [Plans, Outcomes, and Safe Retries](execution-contract.md) + - [Execution Safety Upgrade](execution-safety.md) - [Usage Scenarios](usage-scenarios.md) - [Security Guidelines](security-guidelines.md) - [Troubleshooting](troubleshooting.md) diff --git a/docs/apply.md b/docs/apply.md index 68358be..0446c58 100644 --- a/docs/apply.md +++ b/docs/apply.md @@ -7,6 +7,14 @@ sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ --expect-sha256= --sudo --json ``` +## Private staging on another filesystem + +With `--sudo`, `--stage-dir=/absolute/private/path` overrides the SFTP staging +area under the remote home. It is included in the plan hash and retains the +private-directory/symlink checks. `--backup-dir` remains independent; the final +publication still uses the target filesystem's atomic primitive. MCP exposes +`stage_dir` with the same sudo requirement. See [execution safety](execution-safety.md). + ## What Apply Does 1. Refuse anything that is not a clean absolute regular-file path. diff --git a/docs/execution-safety.md b/docs/execution-safety.md new file mode 100644 index 0000000..cd95c2c --- /dev/null +++ b/docs/execution-safety.md @@ -0,0 +1,104 @@ +# Execution safety in v0.20 + +This release repairs execution boundaries without adding a resident service or +weakening file integrity, host-key trust, or safe-retry semantics. + +## Sudo authentication is not application input + +Noninteractive command auto-fill, `run --sudo`, `apply --sudo`, +`inspect --sudo`, `sql --sudo`, and privileged `text` reads use a session-owned +prompt/readiness handshake: + +1. A random prompt marker identifies a sudo authentication request. +2. Only that request authorizes sending one credential line. +3. A separate child-ready marker authorizes sending the application payload. +4. NOPASSWD, cached authentication and root do not receive a credential line. +5. Repeated prompts, missing readiness, cancellation and I/O errors fail closed. + +Markers are removed from stderr even when split across SSH packets. Secrets +never enter argv, environment variables or temporary secret files. The +handshake has a 30-second readiness limit; configured command/host/global +limits can end it earlier. Passwords containing NUL/CR/LF are rejected rather +than creating ambiguous input framing. + +Auto-fill still applies only to a **leading sudo**. The readiness wrapper uses +`sh -c` with positional argv; quoted arguments and outer pipelines remain at +the original shell boundary. This requires permission to run that shell under +sudo. A command-only sudoers grant may therefore reject auto-fill; do not use +force or expand privileges automatically. Use an operator-approved execution +path instead. Ambiguous sudo shell/login options (`-s`, `-i`) are rejected by +the noninteractive auto-fill path. Do not redirect its control stderr away; +missing readiness does not authorize an unconditional password write. + +**Human login:** `sshx login HOST --sudo` runs `sudo -i` with authentication +handled by the human TTY. It no longer reads or prefills the stored sudo +credential. SSH-login credential references are unaffected. + +### Verification + +`TestSudoInputBoundary` exercises the compiled CLI and actual shells under both +password-consuming and non-consuming policies. Packet-split and cancellation +regressions cover the control protocol. `TestNativeSudoInputBoundary` is enabled +on disposable Linux CI users and checks native PASSWORD, NOPASSWD, cached, +root and wrong-password cases. It must not be enabled against production +accounts. Other OS sudo policies require their own native verification; an +SSH fixture is not evidence for every PAM or sudo configuration. + +## RouterOS + +- `ros raw` preserves the supplied command text. It no longer sorts parameters, + removes quoting, or implicitly adds `without-paging`. Include paging options + in the raw command when required. Structured commands retain their encoder. +- Recognized device diagnostic lines fail even when SSH reports exit status 0. + Transport exit, domain acceptance and effect verification remain distinct; + successful arbitrary mutations do not imply verified postconditions. +- ROS connections use configured jump hosts, SSH credential references and + host/global budgets. Results and audit share finalized execution evidence. +- File upload/download reuse the guarded SFTP implementation: no direct + truncate-and-copy path, no silent delete-before-replace, and no symlink + destination overwrite. Backup/export use unique default remote filenames. +- `script put` retries as `set` only after a positively observed duplicate-name + diagnostic, never after an uncertain transport outcome. +- ROS offline plan binding is still unsupported: `--expect-plan` is rejected, + not silently ignored. Raw commands are not a security sandbox. + +## SFTP and privileged apply + +Transfers use a bounded 1 MiB aperture and at most 32 outstanding requests per +file. This preserves content verification, error ownership and partial-effect +records while avoiding a serial round trip per 32 KiB packet. An upload still +reads the destination back for verification; completion and verification are +not interchangeable. JSON remains one final document. + +For a host whose home filesystem lacks space: + +```bash +sshx apply -h=prod --sudo --path=/data/app/config.yml --from=./config.yml \ + --stage-dir=/data/sshx-stage --backup-dir=/data/sshx-backups --dry-run --json +``` + +`--stage-dir` requires `--sudo`, is an absolute remote path and is included in +the reviewed plan. Existing private-directory, symlink and permission checks +remain active. It controls the unprivileged SFTP payload staging area, **not** +the final same-filesystem publication primitive or the backup directory. +Changing it changes the plan hash. MCP apply exposes the same `stage_dir` field. + +## Trust, deadlines and audit + +- Known host-key algorithms are preferred before fallback negotiation; the + final host-key/certificate/revocation callback remains authoritative. + Missing algorithm enrollment is distinct from a changed key of a known type. +- `--host-test` retains its caller's trust path, cancellation context and + deadlines. Its command probe has a bounded default when no timeout is set. +- Audit redaction covers named secret suffixes (such as `--reverse-token`), + authorization headers and escaped continuations. Redaction metadata names + its policy and explicitly reports `completeness=best_effort`. + Arbitrary program output and unknown secret encodings are not guaranteed + secret-free; do not print credential-bearing configurations to a shared log. +- Plan schema stays `sshx.plan.v1`; execution semantics advance to + `sshx.execution.v2`. **Re-preview plans after upgrading.** A pre-upgrade hash + is not authorization for the changed execution semantics. + +Audit persistence remains independent from remote success. A cancellation or +verification failure after publication can still mean the destination changed; +inspect state and known backups before retrying a mutation. diff --git a/docs/getting-started.md b/docs/getting-started.md index 3f6d8cd..0f4b321 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -88,8 +88,8 @@ sshx login -h=prod-web --sudo sshx login prod-web --dry-run --json ``` -`--sudo` starts a privileged login shell after feeding the host sudo secret -from the OS keyring. Login requires a local TTY, is POSIX-only, and is not +`--sudo` starts `sudo -i`; authenticate at the human TTY when prompted. +No stored sudo credential is read or prefilled. Login requires a local TTY, is POSIX-only, and is not part of the Agent/MCP contract. ## Save A Sudo Password diff --git a/docs/roadmap.md b/docs/roadmap.md index 5bccf2e..f1456f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -91,7 +91,7 @@ Agent / 自动化 / 人类运维者 - **人类交互登录** - `sshx login [--sudo]` 把本地 TTY 接到已解析主机上的交互会话;`--sudo` 经 stdin 注入 keyring sudo 秘密后进入特权 login shell。`--json` 仅配合 `--dry-run`。不进入 MCP,不支持多主机。POSIX only。证据:`internal/app/login.go`、`internal/sshclient/login.go`。 + `sshx login [--sudo]` 把本地 TTY 接到已解析主机上的交互会话;`--sudo` 启动 `sudo -i`,由人类 TTY 完成认证,不预填 sudo 凭据。`--json` 仅配合 `--dry-run`。不进入 MCP,不支持多主机。POSIX only。证据:`internal/app/login.go`、`internal/sshclient/login.go`。 - **单次远程命令执行** @@ -272,6 +272,18 @@ issue #71 的新增边界、验证状态及外部前提单列在后面的证据 不能据此声称完整平台/引擎验收已通过。任何一级能力不得只以参数解析或组件测试 作为完成依据;必须补充相应外部边界 E2E,并如实记录不可用环境与未证明的保证。 +### v0.20 边界回归证据 + +| 能力 | 验收边界 | 证据 | +| --- | --- | --- | +| sudo 输入隔离 | 编译 CLI + 实际 shell 的消费/不消费认证分支;Linux CI 原生 PASSWORD/NOPASSWD/cache/root/错误密码 | `tests/e2e/sudo_boundary_e2e_test.go`、`sudo_native_e2e_test.go`;控制标记分包/取消回归在 `internal/sshclient/sudo_test.go` | +| ROS 契约与文件安全 | raw 字节、exit 0 设备错误、JSON/审计一致、symlink 下载保留原文件;已有 SSH/SFTP 成功/权限用例继续适用 | `tests/e2e/ros_boundary_e2e_test.go`、`ros_e2e_test.go` | +| 主机信任与期限 | 单算法 pin + 多算法服务器;host-test 全链路超时;准入拒绝也不泄露 token | `tests/e2e/audit_boundaries_e2e_test.go` | +| SFTP / apply stage | 协议级有界并发与完整字节;显式 stage 计划变化、发布、清理 | `internal/sshclient/sftp_pipeline_test.go`、`tests/e2e/apply_stage_e2e_test.go` | + +这些 fixture 不代表所有 RouterOS 版本、所有 PAM/sudo 策略已经原生验收。 +兼容边界见 [执行安全说明](execution-safety.md)。 + ## Issue 71 execution hardening evidence 本轮是执行原语加固,不是产品扩张;上述非目标不变。公开契约见 diff --git a/docs/zh/apply.md b/docs/zh/apply.md index e13ca19..30cf7e4 100644 --- a/docs/zh/apply.md +++ b/docs/zh/apply.md @@ -7,6 +7,13 @@ sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ --expect-sha256= --sudo --json ``` +## 独立私有暂存目录 + +`--sudo --stage-dir=/绝对/私有/目录` 可把 SFTP 暂存放到 home 以外的文件系统, +解决根盘满而目标数据盘仍有空间的场景。该路径纳入 plan hash,并保持私有权限和 +符号链接检查;它不改变 `--backup-dir`,最终发布仍使用目标文件系统内的原子操作。 +MCP 对应 `stage_dir`,同样要求 sudo。参见 [执行安全说明](../execution-safety.md)。 + ## Apply 做什么 1. 拒绝非干净绝对路径、目录、符号链接和设备节点。 diff --git a/docs/zh/getting-started.md b/docs/zh/getting-started.md index 0f238f9..d77193c 100644 --- a/docs/zh/getting-started.md +++ b/docs/zh/getting-started.md @@ -87,7 +87,7 @@ sshx login -h=prod-web --sudo sshx login prod-web --dry-run --json ``` -`--sudo` 会用 keyring 里的 sudo 秘密先提权,再进入特权 login shell。登录需要本地 TTY,目前只支持 POSIX,也不属于 Agent/MCP 契约。 +`--sudo` 启动 `sudo -i`,需要时由操作者在 TTY 认证;不读取或预填 keyring sudo 密码。登录需要本地 TTY,目前只支持 POSIX,也不属于 Agent/MCP 契约。 ## 保存 sudo 密码 diff --git a/go.mod b/go.mod index 8791b8f..dedb434 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/talkincode/sshx -go 1.26.8 +go 1.26.10 require ( github.com/modelcontextprotocol/go-sdk v1.7.0 diff --git a/internal/app/apply.go b/internal/app/apply.go index 07b0a31..6c7a32a 100644 --- a/internal/app/apply.go +++ b/internal/app/apply.go @@ -142,6 +142,7 @@ func HandleApply(config *sshclient.Config, audit *auditRecorder) (err error) { ExpectSHA256: config.ApplyExpectSHA256, Backup: !config.ApplyNoBackup, BackupDir: config.ApplyBackupDir, + StageDir: config.ApplyStageDir, Force: config.Force, UseSudo: config.ApplyUseSudo, SudoConfigured: config.SudoKeyConfigured, @@ -177,6 +178,16 @@ func validateApplyConfig(config *sshclient.Config) error { } config.ApplyBackupDir = dir } + if config.ApplyStageDir != "" { + if !config.ApplyUseSudo { + return fmt.Errorf("--stage-dir requires --sudo") + } + dir := path.Clean(config.ApplyStageDir) + if err := sshclient.ValidateApplyPath(dir + "/.keep"); err != nil { + return fmt.Errorf("invalid --stage-dir: %w", err) + } + config.ApplyStageDir = dir + } normalized, err := sshclient.NormalizeApplySHA256(config.ApplyExpectSHA256) if err != nil { return fmt.Errorf("invalid --expect-sha256: %w", err) diff --git a/internal/app/audit.go b/internal/app/audit.go index 876df35..2f29066 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -33,9 +33,11 @@ type auditStatus struct { } type auditRedaction struct { - SecretsRedacted bool `json:"secrets_redacted"` - StdoutOmitted bool `json:"stdout_omitted"` - StderrOmitted bool `json:"stderr_omitted"` + Policy string `json:"policy,omitempty"` + Completeness string `json:"completeness,omitempty"` + SecretsRedacted bool `json:"secrets_redacted"` + StdoutOmitted bool `json:"stdout_omitted"` + StderrOmitted bool `json:"stderr_omitted"` } type auditEvent struct { @@ -182,10 +184,10 @@ type auditRecorder struct { } var ( - sensitiveQuotedAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`) - sensitiveQuotedFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`) - sensitiveAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=([^\s&;]+)`) - sensitiveFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))([^\s]+)`) + sensitiveQuotedAssignmentRE = regexp.MustCompile(`(?i)\b([a-z0-9_-]*(?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|authorization))=("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`) + sensitiveQuotedFlagRE = regexp.MustCompile(`(?i)(--[a-z0-9_-]*(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`) + sensitiveAssignmentRE = regexp.MustCompile(`(?i)\b([a-z0-9_-]*(?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|authorization))=([^\s&;]+)`) + sensitiveFlagRE = regexp.MustCompile(`(?i)(--[a-z0-9_-]*(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))([^\s]+)`) ) func newAuditRecorder(config *sshclient.Config) *auditRecorder { @@ -209,6 +211,7 @@ func newAuditRecorder(config *sshclient.Config) *auditRecorder { HostInput: config.Host, Outcome: auditStatus{Status: "started"}, Redaction: auditRedaction{ + Policy: "sshx.secret-shapes.v2", Completeness: "best_effort", SecretsRedacted: true, StdoutOmitted: true, StderrOmitted: true, @@ -711,6 +714,8 @@ func expandHome(path string) (string, error) { func auditAction(config *sshclient.Config) string { switch config.Mode { + case "ros": + return config.ROSAction case "ssh": return "command" case "sftp": @@ -774,7 +779,7 @@ func auditWouldReadSecret(config *sshclient.Config) bool { case "text": return config.TextUseSudo && config.SudoKey != "" case "login": - return (config.LoginUseSudo && config.SudoKey != "") || config.SSHPasswordKey != "" + return config.SSHPasswordKey != "" default: return false } @@ -791,6 +796,11 @@ func auditLocalManagementEffects(config *sshclient.Config) (execution.Risk, exec func auditWouldMutateRemote(config *sshclient.Config) bool { switch config.Mode { + case "ros": + if p := preparedFrom(config); p != nil { + return p.meta.Effects.RemoteWrite + } + return true case "ssh": return config.Command != "" case "sftp": @@ -814,10 +824,15 @@ func auditWouldMutateRemote(config *sshclient.Config) bool { } } +var auditContinuationRE = regexp.MustCompile(`\\\r?\n[ \t]*`) +var auditAuthorizationRE = regexp.MustCompile(`(?i)(\bauthorization[=:][ \t]*)(?:"[^"\r\n]*"|'[^'\r\n]*'|(?:bearer|basic)[ \t]+[^\s'";]+|[^\s'";]+)`) + func redactSensitiveText(value string) string { if value == "" { return "" } + value = auditContinuationRE.ReplaceAllString(value, "") + value = auditAuthorizationRE.ReplaceAllString(value, "$1") value = sensitiveQuotedAssignmentRE.ReplaceAllString(value, "$1=") value = sensitiveQuotedFlagRE.ReplaceAllString(value, "$1") value = sensitiveAssignmentRE.ReplaceAllString(value, "$1=") diff --git a/internal/app/config.go b/internal/app/config.go index 7d715ff..c9ea69d 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -1113,6 +1113,8 @@ func parseApplyArgs(config *sshclient.Config, args []string) { config.ApplyExpectSHA256 = strings.SplitN(arg, "=", 2)[1] case strings.HasPrefix(arg, "--backup-dir="): config.ApplyBackupDir = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--stage-dir="): + config.ApplyStageDir = strings.SplitN(arg, "=", 2)[1] case arg == "--no-backup": config.ApplyNoBackup = true case arg == "--sudo": @@ -1480,6 +1482,7 @@ func parseROSArgs(config *sshclient.Config, args []string) { config.ROSAllowWrite = true case arg == "--force", arg == "-f": config.Force = true + case applyViaFlag(config, arg): case arg == "--raw": config.ROSRaw = true case strings.HasPrefix(arg, "--routeros-version="), strings.HasPrefix(arg, "--ros-version="): //nolint:misspell // domain name for MikroTik RouterOS diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index 5641979..7953b6f 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -133,6 +133,7 @@ type applyDryRunPlan struct { ExpectSHA256 string `json:"expect_sha256,omitempty"` Backup string `json:"backup"` BackupDir string `json:"backup_dir,omitempty"` + StageDir string `json:"stage_dir,omitempty"` UseSudo bool `json:"use_sudo"` Force bool `json:"force,omitempty"` } @@ -709,7 +710,7 @@ func fillDryRunLogin(config *sshclient.Config, plan *dryRunPlan) { "login has no command timeout", ) if config.LoginUseSudo { - plan.Notes = append(plan.Notes, "would start a privileged login shell after sudo -S; the password is never placed in argv") + plan.Notes = append(plan.Notes, "would start sudo -i; authentication is handled by the human TTY, without sudo secret lookup or prefill") } if !sshclient.InteractiveLoginSupported() { plan.Notes = append(plan.Notes, "interactive login session is not supported on this platform; dry-run still previews the local plan") @@ -773,7 +774,7 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) { case "login": plan.WouldConnect = canProceed plan.WouldExecute = canProceed - plan.WouldReadSecret = canProceed && ((config.LoginUseSudo && config.SudoKey != "") || config.SSHPasswordKey != "") + plan.WouldReadSecret = canProceed && config.SSHPasswordKey != "" plan.WouldMutateRemote = canProceed case "text": plan.WouldConnect = canProceed @@ -937,6 +938,7 @@ func fillDryRunApply(config *sshclient.Config, plan *dryRunPlan) { ExpectSHA256: config.ApplyExpectSHA256, Backup: "file", BackupDir: config.ApplyBackupDir, + StageDir: config.ApplyStageDir, UseSudo: config.ApplyUseSudo, Force: config.Force, } diff --git a/internal/app/host_manager.go b/internal/app/host_manager.go index 5758395..64f2502 100644 --- a/internal/app/host_manager.go +++ b/internal/app/host_manager.go @@ -2,6 +2,7 @@ package app import ( "bufio" + "context" "encoding/json" "errors" "fmt" @@ -889,6 +890,16 @@ func runHostDiagnostics(hostConfig *HostConfig, settings *Settings, baseConfig * } sshConfig := buildHostTestConfig(hostConfig, settings, baseConfig) + if sshConfig.Context == nil { + sshConfig.Context = context.Background() + } + budget := sshConfig.HostTimeout + if budget <= 0 { + budget = sshConfig.Timeout + } + ctx, cancel := context.WithTimeout(sshConfig.Context, budget) + defer cancel() + sshConfig.Context = ctx if hopErr := materializeJumpChain(sshConfig, settings); hopErr != nil { result.ConnectionError = hopErr return result @@ -929,23 +940,17 @@ func runHostDiagnostics(hostConfig *HostConfig, settings *Settings, baseConfig * } func buildHostTestConfig(hostConfig *HostConfig, settings *Settings, baseConfig *sshclient.Config) *sshclient.Config { - testConfig := &sshclient.Config{ - Host: hostConfig.Host, - Port: hostConfig.Port, - User: hostConfig.User, - UseKeyAuth: true, - DialTimeout: hostTestDialTimeout, - HostAlias: hostConfig.Name, - Via: hostConfig.Via, - } - + testConfig := &sshclient.Config{UseKeyAuth: true} if baseConfig != nil { - testConfig.UseKeyAuth = baseConfig.UseKeyAuth - testConfig.KeyPath = baseConfig.KeyPath - testConfig.Password = baseConfig.Password - if baseConfig.DialTimeout > 0 { - testConfig.DialTimeout = baseConfig.DialTimeout - } + *testConfig = *baseConfig + } + testConfig.Host, testConfig.Port, testConfig.User = hostConfig.Host, hostConfig.Port, hostConfig.User + testConfig.HostAlias, testConfig.Via = hostConfig.Name, hostConfig.Via + if testConfig.DialTimeout <= 0 { + testConfig.DialTimeout = hostTestDialTimeout + } + if testConfig.Timeout <= 0 { + testConfig.Timeout = hostTestDialTimeout } if baseConfig != nil && baseConfig.BindSet { testConfig.Bind = baseConfig.Bind diff --git a/internal/app/login.go b/internal/app/login.go index 5f971fe..d6108ad 100644 --- a/internal/app/login.go +++ b/internal/app/login.go @@ -38,15 +38,6 @@ func HandleLogin(config *sshclient.Config, audit *auditRecorder) (err error) { } fillLoginSSHPassword(config) - if config.LoginUseSudo { - password, pwdErr := sshclient.GetSudoPassword(config.SudoKey) - if pwdErr != nil { - return failLogin(config, audit, sshclient.AuthMethodUnknown, "secret", - fmt.Errorf("resolve sudo password key %q: %w", config.SudoKey, pwdErr)) - } - config.SudoPassword = password - } - client, cliErr := sshclient.NewSSHClient(config) if cliErr != nil { return failLogin(config, audit, sshclient.AuthMethodUnknown, "config", diff --git a/internal/app/login_test.go b/internal/app/login_test.go index 52eaf11..a1023e6 100644 --- a/internal/app/login_test.go +++ b/internal/app/login_test.go @@ -81,7 +81,7 @@ func TestLoginDryRunPlan(t *testing.T) { if plan.HostResolved != "10.1.2.3" || plan.HostResolution.Status != "direct" { t.Fatalf("literal address should not look up settings: %+v", plan.HostResolution) } - if !plan.UsesSudo || !plan.WouldConnect || !plan.WouldReadSecret || !plan.WouldMutateRemote { + if !plan.UsesSudo || !plan.WouldConnect || plan.WouldReadSecret || !plan.WouldMutateRemote { t.Fatalf("unexpected login effects: %+v", plan) } if plan.Command != sshclient.PrivilegedLoginCommand { diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 6b7320e..dd0c0c8 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -773,6 +773,7 @@ type mcpApplyInput struct { ExpectSHA256 string `json:"expect_sha256,omitempty" jsonschema:"Fail closed unless the current remote hash matches."` NoBackup bool `json:"no_backup,omitempty" jsonschema:"Skip the pre-change backup; requires force."` BackupDir string `json:"backup_dir,omitempty" jsonschema:"Remote backup directory (default ~/.sshx/file-backups)."` + StageDir string `json:"stage_dir,omitempty" jsonschema:"Private remote SFTP staging directory; requires sudo and is included in the plan."` Sudo bool `json:"sudo,omitempty" jsonschema:"Stage over SFTP, then install with a privileged stdin script."` Force bool `json:"force,omitempty" jsonschema:"Skip the hash precondition; required with no_backup."` BypassReason string `json:"bypass_reason,omitempty" jsonschema:"Required with force when overwriting critical identity files."` @@ -1068,6 +1069,12 @@ func buildApplyArgs(in mcpApplyInput, fromPath string) ([]string, error) { return nil, fmt.Errorf("path is required") } args := []string{"apply", "--json", "-h=" + in.Target, "--path=" + in.Path, "--from=" + fromPath} + if in.StageDir != "" { + if !in.Sudo { + return nil, fmt.Errorf("stage_dir requires sudo") + } + args = append(args, "--stage-dir="+in.StageDir) + } if in.ExpectSHA256 != "" { args = append(args, "--expect-sha256="+in.ExpectSHA256) } diff --git a/internal/app/plan.go b/internal/app/plan.go index bed1ae5..657ad7c 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -139,6 +139,7 @@ func prepareOperation(config *sshclient.Config) (*preparedOperation, error) { plan.Inputs["remote_path"] = config.RemotePath plan.Inputs["expect_sha256"] = config.ApplyExpectSHA256 plan.Inputs["backup_dir"] = config.ApplyBackupDir + plan.Inputs["stage_dir"] = config.ApplyStageDir plan.Inputs["no_backup"] = strconv.FormatBool(config.ApplyNoBackup) case "sftp": plan.Inputs["remote_path"] = config.RemotePath diff --git a/internal/app/ros.go b/internal/app/ros.go index ee6d39c..83ba5f9 100644 --- a/internal/app/ros.go +++ b/internal/app/ros.go @@ -1,6 +1,7 @@ package app import ( + "context" "encoding/json" "fmt" "os" @@ -17,30 +18,43 @@ import ( // rosJSONResult is the machine-readable result emitted in --json mode for RouterOS commands. type rosJSONResult struct { - SchemaVersion string `json:"schema_version,omitempty"` - Status string `json:"status"` // "ok" or "error" - Host string `json:"host,omitempty"` - Port string `json:"port,omitempty"` - User string `json:"user,omitempty"` - Command string `json:"command"` - Action string `json:"action,omitempty"` - RouterOSPath string `json:"routeros_path,omitempty"` //nolint:misspell // RouterOS domain term - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - Data any `json:"data,omitempty"` - ExitCode int `json:"exit_code"` - Success bool `json:"success"` - DurationMs int64 `json:"duration_ms"` - AuthMethod string `json:"auth_method,omitempty"` - ErrorKind string `json:"error_kind,omitempty"` - Error string `json:"error,omitempty"` - Phase string `json:"phase"` - Completion string `json:"completion"` + SchemaVersion string `json:"schema_version,omitempty"` + Status string `json:"status"` // "ok" or "error" + Host string `json:"host,omitempty"` + Port string `json:"port,omitempty"` + User string `json:"user,omitempty"` + Command string `json:"command"` + Action string `json:"action,omitempty"` + RouterOSPath string `json:"routeros_path,omitempty"` //nolint:misspell // RouterOS domain term + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Data any `json:"data,omitempty"` + ExitCode int `json:"exit_code"` + Success bool `json:"success"` + DurationMs int64 `json:"duration_ms"` + AuthMethod string `json:"auth_method,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Error string `json:"error,omitempty"` + Phase string `json:"phase"` + Completion string `json:"completion"` + Executed *bool `json:"executed,omitempty"` + ChangeState string `json:"change_state,omitempty"` + RemoteExitCode *int `json:"remote_exit_code,omitempty"` } // HandleROS manages RouterOS command execution and workflows strictly over SSH. func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { start := time.Now() + if config.Context == nil { + config.Context = context.Background() + } + attachPrepared(config, &preparedOperation{meta: execution.NewMetadata(nil, config.ExecutionID), audit: audit}) + setROSOperation(config, "introspect", strings.Join(config.ROSTokens, " "), false, false, false) + if config.HostTimeout > 0 { + ctx, cancel := execution.WithScopedTimeout(config.Context, config.HostTimeout, "host") + defer cancel() + config.Context = ctx + } if config.ArgumentError != "" { return reportROSFailure(config, audit, "config", fmt.Errorf("%s", config.ArgumentError), start) @@ -104,6 +118,7 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { // 2. File and script workflows if wfSpec, isWf := ros.ParseWorkflowCommand(tokens, config.ROSSource); isWf { + rosWorkflowIntent(config, wfSpec) if config.DryRun { planJSON, pErr := ros.RenderWorkflowPlanJSON(wfSpec) if pErr != nil { @@ -121,6 +136,7 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { return reportROSFailure(config, audit, "usage", parseErr, start) } + setROSOperation(config, strings.Join(req.Path, "/")+"/"+req.Action, ros.BuildRouterOSCommand(req), !ros.ReadOnly(req), false, !ros.ReadOnly(req)) // Check dry-run if config.DryRun { plan := &ros.ROSPlan{ @@ -134,7 +150,12 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { SideEffects: req.Mapping.SideEffects, Idempotency: req.Mapping.Idempotency, WillConnect: false, - WillModifyRouterOS: req.Mapping.ActionKind != ros.ActionPrint, + WillModifyRouterOS: !ros.ReadOnly(req), + } + if req.Mapping.IsRaw() { + plan.RawCommand = req.RawCommand + plan.ResolvedArgs = map[string]string{} + plan.Flags = []string{} } b, mErr := json.MarshalIndent(plan, "", " ") if mErr != nil { @@ -161,13 +182,13 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { } } - client, cliErr := sshclient.NewSSHClient(config) + client, cliErr := newROSClient(config) if cliErr != nil { return reportROSFailure(config, audit, "config", fmt.Errorf("failed to create SSH client: %w", cliErr), start) } defer errutil.HandleCloseError(&err, client) - if connErr := client.ConnectDirect(); connErr != nil { + if connErr := client.Connect(); connErr != nil { audit.recordPeer(client) return reportROSFailure(config, audit, classifyError(connErr), fmt.Errorf("failed to connect over SSH: %w", connErr), start) } @@ -177,9 +198,9 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { rosCmd := ros.BuildRouterOSCommand(req) logger.GetLogger().Debug("executing RouterOS command over SSH: %s", rosCmd) - res, execErr := client.RunCommandWithInput(rosCmd, nil) + res, execErr := runROSCommand(client, rosCmd) if execErr != nil { - return reportROSFailure(config, audit, classifyError(execErr), execErr, start) + return reportROSFailureWithExit(config, audit, classifyError(execErr), res, execErr, start) } if res.ExitCode != 0 { @@ -205,7 +226,7 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { Host: config.Host, Port: config.Port, User: config.User, - Command: strings.Join(tokens, " "), + Command: redactSensitiveText(config.Command), Action: req.Action, RouterOSPath: req.Mapping.RouterOSPath, Stdout: res.Stdout, @@ -223,6 +244,9 @@ func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { return emitROSJSON(config, result) } + if _, finalizeErr := finalizeLifecycle(config, result); finalizeErr != nil { + return finalizeErr + } // Human output: print stdout cleanly if strings.TrimSpace(res.Stdout) != "" { fmt.Print(res.Stdout) @@ -243,13 +267,13 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo _ = resolveHostFromSettings(config) //nolint:errcheck // best-effort settings lookup } - client, cliErr := sshclient.NewSSHClient(config) + client, cliErr := newROSClient(config) if cliErr != nil { return reportROSFailure(config, audit, "config", fmt.Errorf("failed to create SSH client: %w", cliErr), start) } defer errutil.HandleCloseError(&err, client) - if connErr := client.ConnectDirect(); connErr != nil { + if connErr := client.Connect(); connErr != nil { audit.recordPeer(client) return reportROSFailure(config, audit, classifyError(connErr), connErr, start) } @@ -267,20 +291,10 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo switch spec.Type { case ros.WorkflowFileUpload: - bytesCopied, uploadErr := ros.ExecuteSFTPUpload(sftpClient, spec.LocalPath, spec.RemotePath) - if uploadErr != nil { - return reportROSFailure(config, audit, "file_transfer_failed", uploadErr, start) - } - outputMsg = fmt.Sprintf("Uploaded %s to %s (%d bytes)", spec.LocalPath, spec.RemotePath, bytesCopied) - resultData = map[string]any{"bytes": bytesCopied, "local": spec.LocalPath, "remote": spec.RemotePath} + return finishROSFile(config, audit, client, "upload", spec.LocalPath, spec.RemotePath, start) case ros.WorkflowFileDownload: - bytesCopied, downloadErr := ros.ExecuteSFTPDownload(sftpClient, spec.RemotePath, spec.LocalPath) - if downloadErr != nil { - return reportROSFailure(config, audit, "file_transfer_failed", downloadErr, start) - } - outputMsg = fmt.Sprintf("Downloaded %s to %s (%d bytes)", spec.RemotePath, spec.LocalPath, bytesCopied) - resultData = map[string]any{"bytes": bytesCopied, "remote": spec.RemotePath, "local": spec.LocalPath} + return finishROSFile(config, audit, client, "download", spec.LocalPath, spec.RemotePath, start) case ros.WorkflowFileList: entries, listErr := sftpClient.ReadDir(spec.RemotePath) @@ -307,14 +321,18 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo if valErr := ros.ValidateScriptSource(content, spec.LocalPath); valErr != nil { return reportROSFailure(config, audit, "usage", valErr, start) } - // Escape script source for RouterOS CLI - escapedSource := strings.ReplaceAll(string(content), `"`, `\"`) - cmd := fmt.Sprintf(`/system script add name=%q source="%s"`, spec.ScriptName, escapedSource) - res, execErr := client.RunCommandWithInput(cmd, nil) + escapedSource := ros.QuoteValue(string(content)) + cmd := fmt.Sprintf(`/system script add name=%s source=%s`, ros.QuoteValue(spec.ScriptName), escapedSource) + res, execErr := runROSCommand(client, cmd) if execErr != nil || res.ExitCode != 0 { - // If already exists, try set - cmdSet := fmt.Sprintf(`/system script set [find name=%q] source="%s"`, spec.ScriptName, escapedSource) - resSet, execSetErr := client.RunCommandWithInput(cmdSet, nil) + // Only a positively observed duplicate-name diagnostic authorizes set; + // an unknown outcome must not silently trigger a second mutation. + message := strings.ToLower(res.Stdout + res.Stderr) + if !res.ExitObserved || (!strings.Contains(message, "already have such name") && !strings.Contains(message, "already exists")) { + return reportROSFailureWithExit(config, audit, "ros_exec_error", res, fmt.Errorf("failed to add script: %s", redactSensitiveText(message)), start) + } + cmdSet := fmt.Sprintf(`/system script set [find name=%s] source=%s`, ros.QuoteValue(spec.ScriptName), escapedSource) + resSet, execSetErr := runROSCommand(client, cmdSet) if execSetErr != nil || resSet.ExitCode != 0 { return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("failed to put script: %s", firstNonEmpty(res.Stderr, resSet.Stderr)), start) } @@ -324,13 +342,13 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo case ros.WorkflowImport: // 1. Upload script - _, uploadErr := ros.ExecuteSFTPUpload(sftpClient, spec.LocalPath, spec.RemotePath) + _, uploadErr := copyROSFile(client, config, "upload", spec.LocalPath, spec.RemotePath) if uploadErr != nil { return reportROSFailure(config, audit, "file_transfer_failed", uploadErr, start) } // 2. Run /import - importCmd := fmt.Sprintf("/import file-name=%q", spec.RemotePath) - res, execErr := client.RunCommandWithInput(importCmd, nil) + importCmd := "/import file-name=" + ros.QuoteValue(spec.RemotePath) + res, execErr := runROSCommand(client, importCmd) // 3. Cleanup if requested if config.ROSCleanup { _ = sftpClient.Remove(spec.RemotePath) //nolint:errcheck // best-effort remote file cleanup @@ -342,13 +360,13 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo resultData = map[string]any{"imported": spec.LocalPath, "output": res.Stdout} case ros.WorkflowExportDownload: - tempFile := "sshx_export_temp.rsc" + tempFile := "sshx_export_" + newAuditEventID() + ".rsc" compactFlag := "" if config.ROSCompact { compactFlag = "compact" } exportCmd := fmt.Sprintf("/export %s file=%s", compactFlag, tempFile) - res, execErr := client.RunCommandWithInput(exportCmd, nil) + res, execErr := runROSCommand(client, exportCmd) if execErr != nil || res.ExitCode != 0 { return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("export failed: %s", firstNonEmpty(res.Stderr, res.Stdout)), start) } @@ -357,7 +375,7 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo return reportROSFailure(config, audit, "file_transfer_failed", waitErr, start) } // Download file - n, dlErr := ros.ExecuteSFTPDownload(sftpClient, tempFile, spec.LocalPath) + n, dlErr := copyROSFile(client, config, "download", spec.LocalPath, tempFile) if config.ROSCleanup { _ = sftpClient.Remove(tempFile) //nolint:errcheck // best-effort remote file cleanup } @@ -370,11 +388,11 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo case ros.WorkflowBackupDownload: backupName := config.ROSBackupName if backupName == "" { - backupName = "sshx_backup_temp" + backupName = "sshx_backup_" + newAuditEventID() } backupFile := backupName + ".backup" - backupCmd := fmt.Sprintf("/system backup save name=%s", backupName) - res, execErr := client.RunCommandWithInput(backupCmd, nil) + backupCmd := "/system backup save name=" + ros.QuoteValue(backupName) + res, execErr := runROSCommand(client, backupCmd) if execErr != nil || res.ExitCode != 0 { return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("backup failed: %s", firstNonEmpty(res.Stderr, res.Stdout)), start) } @@ -383,7 +401,7 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo return reportROSFailure(config, audit, "file_transfer_failed", waitErr, start) } // Download file - n, dlErr := ros.ExecuteSFTPDownload(sftpClient, backupFile, spec.LocalPath) + n, dlErr := copyROSFile(client, config, "download", spec.LocalPath, backupFile) if config.ROSCleanup { _ = sftpClient.Remove(backupFile) //nolint:errcheck // best-effort remote file cleanup } @@ -419,6 +437,9 @@ func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.Wo return emitROSJSON(config, result) } + if _, finalizeErr := finalizeLifecycle(config, result); finalizeErr != nil { + return finalizeErr + } logger.GetLogger().Success("%s", outputMsg) return nil } @@ -460,15 +481,15 @@ func handleDoctor(config *sshclient.Config, audit *auditRecorder, start time.Tim SelectedProtocol: "ssh", } - if config.ROSIncludeRemote && config.Host != "" { + if config.ROSIncludeRemote && config.Host != "" && !config.DryRun { if !isIPAddress(config.Host) { _ = resolveHostFromSettings(config) //nolint:errcheck // best-effort settings lookup } - client, cliErr := sshclient.NewSSHClient(config) + client, cliErr := newROSClient(config) if cliErr == nil { defer func() { _ = client.Close() }() //nolint:errcheck // best-effort close - if connErr := client.ConnectDirect(); connErr == nil { - res, _ := client.RunCommandWithInput("/system resource print", nil) //nolint:errcheck // doctor probe ignores exec failure + if connErr := client.Connect(); connErr == nil { + res, _ := runROSCommand(client, "/system resource print") //nolint:errcheck // doctor probe ignores exec failure remoteDoc := &ros.RemoteDoctor{ Status: "connected", } @@ -525,18 +546,38 @@ func reportROSFailureWithExit(config *sshclient.Config, audit *auditRecorder, ki Host: config.Host, Port: config.Port, User: config.User, - Command: strings.Join(config.ROSTokens, " "), + Command: redactSensitiveText(config.Command), ExitCode: res.ExitCode, Success: false, DurationMs: time.Since(start).Milliseconds(), ErrorKind: kind, Error: redactError(err), - Phase: "failed", - Completion: execution.CompletionUnknown, + Phase: execution.PhaseExecute, + Completion: execution.CompletionForAttempt(execution.PhaseExecute, kind, res.StartAttempted, res.Started, res.ExitObserved), + } + if p := preparedFrom(config); p != nil { + result.Executed, result.ChangeState = p.meta.Executed, p.meta.ChangeState + } + if kind == "ros_command_error" && res.ExitObserved { + observed := res.ExitCode + result.RemoteExitCode = &observed + result.ExitCode = -1 + } + if result.Executed != nil && *result.Executed && !res.StartAttempted { + result.Completion = execution.CompletionPartial + } + if res.Started || res.ExitObserved { + value := true + result.Executed = &value + } else if !res.StartAttempted && result.Executed == nil { + value := false + result.Executed = &value + result.Phase = execution.PhaseAdmission } - if config.JSONOutput { - _ = emitROSJSON(config, result) //nolint:errcheck // best-effort error result JSON emission + if emitErr := emitROSJSON(config, result); emitErr != nil { + return emitErr + } return ErrReported } diff --git a/internal/app/ros_lifecycle.go b/internal/app/ros_lifecycle.go new file mode 100644 index 0000000..73929f7 --- /dev/null +++ b/internal/app/ros_lifecycle.go @@ -0,0 +1,95 @@ +package app + +import ( + "fmt" + "strings" + "time" + + "github.com/talkincode/sshx/internal/execution" + "github.com/talkincode/sshx/internal/ros" + "github.com/talkincode/sshx/internal/sshclient" +) + +func runROSCommand(client *sshclient.SSHClient, command string) (sshclient.ExecResult, error) { + result, err := client.RunCommandWithInput(command, nil) + if err == nil && (ros.CommandDiagnostic(result.Stdout) || ros.CommandDiagnostic(result.Stderr)) { + err = &execution.BoundaryError{Kind: "ros_command_error", Message: "RouterOS rejected the command: " + redactSensitiveText(firstNonEmpty(result.Stderr, result.Stdout))} + } + return result, err +} + +func setROSOperation(config *sshclient.Config, action, command string, remoteWrite, localWrite, unknown bool) { + config.ROSAction, config.Command = action, command + if p := preparedFrom(config); p != nil { + p.meta.Effects = execution.Effects{RemoteWrite: remoteWrite, LocalWrite: localWrite, Unknown: unknown} + p.meta.Risk = execution.RiskRead + if remoteWrite || unknown { + p.meta.Risk = execution.RiskMutation + } + p.preview.LocalPath = config.LocalPath + } +} + +func newROSClient(config *sshclient.Config) (*sshclient.SSHClient, error) { + settings, err := LoadSettings() + if err != nil { + return nil, err + } + if err := materializeJumpChain(config, settings); err != nil { + return nil, err + } + if err := resolveSSHCredential(config); err != nil { + return nil, err + } + if err := resolveJumpSecrets(config); err != nil { + return nil, err + } + return sshclient.NewSSHClient(config) +} + +func rosWorkflowIntent(config *sshclient.Config, spec *ros.WorkflowSpec) { + config.LocalPath, config.RemotePath = spec.LocalPath, spec.RemotePath + remoteWrite := spec.Type != ros.WorkflowFileDownload && spec.Type != ros.WorkflowFileList + localWrite := spec.Type == ros.WorkflowFileDownload || spec.Type == ros.WorkflowExportDownload || spec.Type == ros.WorkflowBackupDownload + setROSOperation(config, string(spec.Type), strings.Join(config.ROSTokens, " "), remoteWrite, localWrite, remoteWrite) +} + +func finishROSFile(config *sshclient.Config, audit *auditRecorder, client *sshclient.SSHClient, action, local, remote string, start time.Time) error { + outcome, operationErr := client.CopyFile(action, local, remote) + result, err := fileOperationResult(config, outcome, start, operationErr) + if err != nil { + return err + } + result["schema_version"] = "roswire.write.v1" + result["command"] = config.ROSAction + result["status"] = "ok" + if operationErr != nil { + result["status"] = "error" + } + data := map[string]any{"local": local, "remote": remote, "bytes": int64(0)} + if outcome != nil { + data["bytes"] = outcome.BytesTransferred + } + result["data"] = data + if audit != nil && operationErr == nil { + audit.recordCommandResult(config, client.AuthMethodUsed(), sshclient.ExecResult{ExitCode: 0}, time.Since(start), "", nil) + } + if err := finishOperation(config, audit, result, operationErr); err != nil { + return err + } + if !config.JSONOutput { + fmt.Printf("SFTP %s: %s (%d bytes)\n", action, outcome.Completion, outcome.BytesTransferred) + } + return nil +} + +func copyROSFile(client *sshclient.SSHClient, config *sshclient.Config, action, local, remote string) (int64, error) { + out, err := client.CopyFile(action, local, remote) + if out == nil { + return 0, err + } + if p := preparedFrom(config); p != nil { + p.meta.Executed, p.meta.ChangeState = out.Executed, out.ChangeState + } + return out.BytesTransferred, err +} diff --git a/internal/app/run.go b/internal/app/run.go index 912a021..94bd8e9 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -262,6 +262,9 @@ func reportRunRequestFailure(config *sshclient.Config, audit *auditRecorder, err Error: execution.BuildError(err, kind, execution.IntentUnknown, execution.CompletionNotStarted), ErrorKind: kind, } + if res.Error != nil { + res.Error.Message = redactAuditText(config, res.Error.Message) + } if config.JSONOutput || config.JSONLOutput { if encErr := emitLifecycleJSON(config, res); encErr != nil { return encErr diff --git a/internal/app/sql.go b/internal/app/sql.go index 416ab42..11f9a1f 100644 --- a/internal/app/sql.go +++ b/internal/app/sql.go @@ -336,9 +336,8 @@ func validateSQLiteConfig(config *sshclient.Config) error { } // runRemote executes one assembled remote command, prepending the database -// password line to stdin when password delivery is enabled. --sudo wraps the -// whole command in sudo -S and prepends the sudo password so it never enters -// argv; sudo consumes the first line and the client sees the rest. +// password line to stdin when password delivery is enabled. Sudo authentication +// is a separate handshake; the database input is withheld until child readiness. func (r *sqlRun) runRemote(rc sqlsafe.RemoteCommand) (sshclient.ExecResult, error) { command := rc.Command stdin := rc.Stdin @@ -350,7 +349,7 @@ func (r *sqlRun) runRemote(rc sqlsafe.RemoteCommand) (sshclient.ExecResult, erro return sshclient.ExecResult{ExitCode: -1}, fmt.Errorf("sql --sudo requires a resolved sudo password") } command = sqlsafe.WrapSudoStdin(command) - stdin = r.config.SudoPassword + "\n" + stdin + return r.client.RunSudoCommandWithInput(command, []byte(stdin)) } return r.client.RunCommandWithInput(command, []byte(stdin)) } diff --git a/internal/app/text.go b/internal/app/text.go index 0d41774..f045062 100644 --- a/internal/app/text.go +++ b/internal/app/text.go @@ -191,8 +191,7 @@ func (r *textRun) scanSFTP(req textsafe.Request) (textsafe.Result, error) { func (r *textRun) scanPrivilegedFile(req textsafe.Request) (textsafe.Result, error) { fromEnd := req.Scan == textsafe.ScanEnd cmd := textsafe.FileReadCommand(req.Path, fromEnd, req.MaxScanBytes, true) - stdin := []byte(r.config.SudoPassword + "\n") - result, err := r.client.RunCommandWithInput(cmd, stdin) + result, err := r.client.RunSudoCommandWithInput(cmd, nil) if err != nil { return textsafe.Result{}, err } @@ -205,11 +204,13 @@ func (r *textRun) scanPrivilegedFile(req textsafe.Request) (textsafe.Result, err func (r *textRun) scanJournal(req textsafe.Request) (textsafe.Result, error) { cmd := textsafe.JournalCommand(req, r.config.TextUseSudo) - var stdin []byte + var result sshclient.ExecResult + var err error if r.config.TextUseSudo { - stdin = []byte(r.config.SudoPassword + "\n") + result, err = r.client.RunSudoCommandWithInput(cmd, nil) + } else { + result, err = r.client.RunCommandWithInput(cmd, nil) } - result, err := r.client.RunCommandWithInput(cmd, stdin) if err != nil { return textsafe.Result{}, err } diff --git a/internal/app/usage.go b/internal/app/usage.go index e61ec97..d358c41 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -59,7 +59,7 @@ func usageSections() []usageSection { {title: "Inspection Capabilities", verb: "inspect", summary: "Collect or reuse one structured host observation from a built-in or local capability.", text: usageInspect}, {title: "Guarded SQL Execution", verb: "sql", summary: "Run one guarded SQL statement through the database client already present on the remote host.", text: usageSQL}, {title: "MikroTik RouterOS (ROS) over SSH", verb: "ros", summary: "Manage MikroTik RouterOS devices over SSH and SFTP with structured commands.", text: usageROS}, - {title: "Guarded File Apply", verb: "apply", summary: "Replace one remote regular file with a precondition, backup, and post-write verification pipeline.", text: usageApply}, + {title: "Guarded File Apply", verb: "apply", summary: "Replace one remote file with a precondition, backup, verified publication, and optional private sudo stage directory.", text: usageApply}, {title: "Text Dissection", verb: "", summary: "", text: usageText}, {title: "Interactive Login", verb: "login", summary: "Open one human interactive TTY session on a host already known to sshx.", text: usageLogin}, {title: "Plugin Management", verb: "plugin", summary: "Install, create, list, validate, trust, and remove local inspection plugins.", text: usagePlugin}, diff --git a/internal/app/usage_sections.go b/internal/app/usage_sections.go index f05fb02..f4ef5cc 100644 --- a/internal/app/usage_sections.go +++ b/internal/app/usage_sections.go @@ -502,6 +502,7 @@ Guarded File Apply: --expect-sha256=HEX Fail closed unless the current remote hash matches --no-backup Skip the pre-change copy (requires --force) --backup-dir=PATH Remote backup directory (default: ~/.sshx/file-backups) + --stage-dir=PATH Private SFTP staging directory (--sudo only; plan-bound) --sudo Stage the payload over SFTP, then install with sudo --force, -f Skip the hash precondition; required with --no-backup --bypass-reason=TEXT Required with --force to overwrite /etc/passwd, @@ -584,7 +585,7 @@ Interactive Login: --target=NAME Long alias of -h= / --address=HOST Literal address; skip settings.json resolution --sudo Land in a privileged login shell (sudo -i) - using the host sudo keyring secret on stdin + authenticate at the human TTY; no sudo secret prefill --dry-run / --json Local plan only; --json requires --dry-run Requires a local TTY. POSIX only; Windows returns an explicit unsupported diff --git a/internal/execution/plan.go b/internal/execution/plan.go index 4815824..02c2dac 100644 --- a/internal/execution/plan.go +++ b/internal/execution/plan.go @@ -71,7 +71,7 @@ func (p Plan) CanonicalBytes() ([]byte, error) { func (p *Plan) Seal() error { p.SchemaVersion = PlanSchemaVersion - p.Semantics = "sshx.execution.v1" + p.Semantics = "sshx.execution.v2" if p.Inputs == nil { p.Inputs = map[string]string{} } diff --git a/internal/execution/plan_test.go b/internal/execution/plan_test.go index 1fe0c58..1c16e5d 100644 --- a/internal/execution/plan_test.go +++ b/internal/execution/plan_test.go @@ -14,7 +14,7 @@ func TestPlanCanonicalSerialization(t *testing.T) { require.NoError(t, plan.Seal()) raw, err := plan.CanonicalBytes() require.NoError(t, err) - require.JSONEq(t, `{"schema_version":"sshx.plan.v1","semantics":"sshx.execution.v1","action":"command","targets":[{"role":"target","address":"127.0.0.1","port":"22","user":"worker"}],"inputs":{"a":"<&","z":"last"},"risk":"read","effects":{"unknown":false,"remote_write":false,"local_write":false,"privileged":false,"destructive":false}}`, string(raw)) + require.JSONEq(t, `{"schema_version":"sshx.plan.v1","semantics":"sshx.execution.v2","action":"command","targets":[{"role":"target","address":"127.0.0.1","port":"22","user":"worker"}],"inputs":{"a":"<&","z":"last"},"risk":"read","effects":{"unknown":false,"remote_write":false,"local_write":false,"privileged":false,"destructive":false}}`, string(raw)) require.Contains(t, string(raw), `"inputs":{"a":"\u003c\u0026","z":"last"}`) before := plan.PlanHash plan.Inputs = map[string]string{"a": "<&", "z": "last"} diff --git a/internal/ros/catalog.go b/internal/ros/catalog.go index a68dd4f..9df3f09 100644 --- a/internal/ros/catalog.go +++ b/internal/ros/catalog.go @@ -429,6 +429,7 @@ func ParseInvocation(tokens []string) (*ROSRequest, error) { rawTokenIndex = 1 } + rawText := strings.Join(tokens[rawTokenIndex:], " ") rawPath := strings.TrimSpace(tokens[rawTokenIndex]) var restTokens []string if strings.Contains(rawPath, " ") { @@ -471,7 +472,7 @@ func ParseInvocation(tokens []string) (*ROSRequest, error) { } } - isPrint := actionKind == ActionPrint + isPrint := actionKind == ActionPrint && !strings.ContainsAny(rawText, ";\r\n[]") idempotency := "unknown" var sideEffects []string if isPrint { @@ -483,7 +484,7 @@ func ParseInvocation(tokens []string) (*ROSRequest, error) { return &ROSRequest{ Path: []string{"raw"}, Action: "raw", - RawCommand: rawPath, + RawCommand: rawText, Args: args, Flags: flags, Mapping: &CommandMapping{ @@ -618,49 +619,16 @@ func ParseInvocation(tokens []string) (*ROSRequest, error) { return req, nil } +// QuoteValue encodes one literal RouterOS value, including script source. Raw +// commands bypass this encoder and retain their caller-owned syntax. +func QuoteValue(value string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`, "\r", `\r`, "\n", `\n`, "\t", `\t`).Replace(value) + `"` +} + // BuildRouterOSCommand formats the RouterOS CLI command line string to be executed over SSH. func BuildRouterOSCommand(req *ROSRequest) string { if req.Mapping != nil && req.Mapping.IsRaw() { - var sb strings.Builder - rawCmd := strings.TrimSpace(req.RawCommand) - if rawCmd == "" { - rawCmd = req.Action - } - if !strings.HasPrefix(rawCmd, "/") && !strings.HasPrefix(rawCmd, ":") { - rawCmd = "/" + rawCmd - } - sb.WriteString(rawCmd) - - for _, flag := range req.Flags { - sb.WriteString(" ") - sb.WriteString(flag) - } - - if len(req.Args) > 0 { - keys := make([]string, 0, len(req.Args)) - for k := range req.Args { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - v := req.Args[k] - sb.WriteString(" ") - if strings.Contains(v, " ") && !strings.HasPrefix(v, "\"") { - fmt.Fprintf(&sb, "%s=%q", k, v) - } else { - fmt.Fprintf(&sb, "%s=%s", k, v) - } - } - } - - if req.Mapping.ActionKind == ActionPrint { - lower := strings.ToLower(sb.String()) - if !strings.Contains(lower, "without-paging") { - sb.WriteString(" without-paging") - } - } - - return sb.String() + return req.RawCommand } var sb strings.Builder @@ -679,8 +647,8 @@ func BuildRouterOSCommand(req *ROSRequest) string { for _, k := range keys { v := req.Args[k] sb.WriteString(" ") - if strings.Contains(v, " ") && !strings.HasPrefix(v, "\"") { - fmt.Fprintf(&sb, "%s=%q", k, v) + if strings.ContainsAny(v, " \t\r\n\"\\$;[]") { + fmt.Fprintf(&sb, "%s=%s", k, QuoteValue(v)) } else { fmt.Fprintf(&sb, "%s=%s", k, v) } diff --git a/internal/ros/catalog_test.go b/internal/ros/catalog_test.go index c8e61a0..cafcbaa 100644 --- a/internal/ros/catalog_test.go +++ b/internal/ros/catalog_test.go @@ -90,8 +90,8 @@ func TestParseInvocation(t *testing.T) { t.Errorf("expected raw command, got %+v", reqRaw) } rawStr := BuildRouterOSCommand(reqRaw) - if rawStr != "/system/clock/print without-paging" { - t.Errorf("expected '/system/clock/print without-paging', got %q", rawStr) + if rawStr != "/system/clock/print" { + t.Errorf("raw command bytes must be unchanged, got %q", rawStr) } // 4. Raw command with args and flags diff --git a/internal/ros/safety.go b/internal/ros/safety.go index 305b30f..11906c8 100644 --- a/internal/ros/safety.go +++ b/internal/ros/safety.go @@ -43,7 +43,7 @@ func ValidateSafety(req *ROSRequest, allowWrite bool, force bool) *ROSErr { } // Raw mutative commands require --allow-write or --force - if req.Mapping != nil && req.Mapping.IsRaw() && req.Mapping.ActionKind != ActionPrint { + if req.Mapping != nil && req.Mapping.IsRaw() && !ReadOnly(req) { if !allowWrite && !force { return &ROSErr{ ErrorCode: ErrCodeUsageError, @@ -60,6 +60,24 @@ func ValidateSafety(req *ROSRequest, allowWrite bool, force bool) *ROSErr { return nil } +// ReadOnly recognizes only a single print invocation, never compound scripts +// or expression evaluation. A declaration is not proof of arbitrary raw purity. +func ReadOnly(req *ROSRequest) bool { + return req.Mapping != nil && req.Mapping.ActionKind == ActionPrint && !strings.ContainsAny(BuildRouterOSCommand(req), ";\r\n[]") +} + +// CommandDiagnostic recognizes device diagnostics, not arbitrary occurrences of +// the word error in user data. SSH exit status alone is insufficient on RouterOS. +func CommandDiagnostic(output string) bool { + line := strings.ToLower(strings.TrimSpace(output)) + for _, prefix := range []string{"input does not match any value", "expected end of command", "syntax error", "bad command name", "no such item", "not enough permissions", "failure:", "invalid value for argument"} { + if strings.HasPrefix(line, prefix) { + return true + } + } + return false +} + // ValidateScriptSource checks size and UTF-8 validity of script contents. func ValidateScriptSource(content []byte, sourcePath string) *ROSErr { if len(content) > MaxScriptSourceBytes { diff --git a/internal/ros/types.go b/internal/ros/types.go index 215f367..3904f78 100644 --- a/internal/ros/types.go +++ b/internal/ros/types.go @@ -71,6 +71,7 @@ type ROSRequest struct { // ROSPlan is the execution plan emitted for dry-run preview. type ROSPlan struct { + RawCommand string `json:"raw_command,omitempty"` SchemaVersion string `json:"schema_version"` DryRun bool `json:"dry_run"` Command string `json:"command"` diff --git a/internal/ros/workflow.go b/internal/ros/workflow.go index 2acfc7c..291563c 100644 --- a/internal/ros/workflow.go +++ b/internal/ros/workflow.go @@ -3,8 +3,6 @@ package ros import ( "encoding/json" "fmt" - "io" - "os" "path/filepath" "strings" "time" @@ -161,60 +159,6 @@ func redactPath(p string) string { return "***REDACTED***/" + filepath.Base(p) } -// ExecuteSFTPUpload copies a local file to remote RouterOS using SFTP. -func ExecuteSFTPUpload(sftpClient *sftp.Client, localPath, remotePath string) (int64, error) { - srcFile, err := os.Open(filepath.Clean(localPath)) // #nosec G304 -- local file path provided via CLI flag - if err != nil { - return 0, fmt.Errorf("open local file: %w", err) - } - defer func() { _ = srcFile.Close() }() //nolint:errcheck // best-effort file close - - stat, err := srcFile.Stat() - if err != nil { - return 0, fmt.Errorf("stat local file: %w", err) - } - - dstFile, err := sftpClient.Create(remotePath) - if err != nil { - return 0, fmt.Errorf("create remote SFTP file %q: %w", remotePath, err) - } - defer func() { _ = dstFile.Close() }() //nolint:errcheck // best-effort file close - - n, err := io.Copy(dstFile, srcFile) - if err != nil { - return n, fmt.Errorf("copy to remote SFTP file: %w", err) - } - if n != stat.Size() { - return n, fmt.Errorf("partial transfer: copied %d of %d bytes", n, stat.Size()) - } - return n, nil -} - -// ExecuteSFTPDownload copies a remote file on RouterOS to local filesystem using SFTP. -func ExecuteSFTPDownload(sftpClient *sftp.Client, remotePath, localPath string) (int64, error) { - srcFile, err := sftpClient.Open(remotePath) - if err != nil { - return 0, fmt.Errorf("open remote SFTP file %q: %w", remotePath, err) - } - defer func() { _ = srcFile.Close() }() //nolint:errcheck // best-effort file close - - if mkdirErr := os.MkdirAll(filepath.Dir(localPath), 0750); mkdirErr != nil { - return 0, fmt.Errorf("create local directory: %w", mkdirErr) - } - - dstFile, err := os.Create(filepath.Clean(localPath)) // #nosec G304 -- local target path provided via CLI flag - if err != nil { - return 0, fmt.Errorf("create local file %q: %w", localPath, err) - } - defer func() { _ = dstFile.Close() }() //nolint:errcheck // best-effort file close - - n, err := io.Copy(dstFile, srcFile) - if err != nil { - return n, fmt.Errorf("copy from remote SFTP file: %w", err) - } - return n, nil -} - // WaitForRemoteFile polls remote path via SFTP until it exists or deadline is exceeded. func WaitForRemoteFile(sftpClient *sftp.Client, remotePath string, timeout time.Duration) error { deadline := time.Now().Add(timeout) diff --git a/internal/sshclient/apply.go b/internal/sshclient/apply.go index d91143f..f6668bb 100644 --- a/internal/sshclient/apply.go +++ b/internal/sshclient/apply.go @@ -45,6 +45,7 @@ type ApplyRequest struct { ExpectSHA256 string Backup bool BackupDir string + StageDir string Force bool UseSudo bool SudoConfigured bool @@ -670,6 +671,12 @@ func (c *SSHClient) applyWithSudo(req ApplyRequest) (outcome *ApplyOutcome, err return outcome, err } stagingDir := path.Join(home, applyStagingDir) + if req.StageDir != "" { + if stageErr := ValidateApplyPath(req.StageDir + "/.keep"); stageErr != nil { + return outcome, fmt.Errorf("invalid stage directory: %w", stageErr) + } + stagingDir = path.Clean(req.StageDir) + } client, clientErr := c.newSFTPClient() if clientErr != nil { return outcome, clientErr diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index a559a45..2beac0e 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -244,6 +244,7 @@ type Config struct { // Guarded file apply fields (Mode == "apply"). ApplyExpectSHA256 string ApplyNoBackup bool + ApplyStageDir string ApplyBackupDir string ApplyUseSudo bool @@ -285,6 +286,7 @@ type Config struct { LoginLiteralHost bool // RouterOS execution fields (Mode == "ros"). + ROSAction string ROSTokens []string ROSAllowWrite bool ROSRouterOSVersion string diff --git a/internal/sshclient/connect.go b/internal/sshclient/connect.go index ed38538..9b0332b 100644 --- a/internal/sshclient/connect.go +++ b/internal/sshclient/connect.go @@ -158,6 +158,7 @@ func (c *SSHClient) connectThrough(parent *ssh.Client) (err error) { localAddr = resolved } addr := net.JoinHostPort(c.config.Host, c.config.Port) + algorithms := preferredHostKeyAlgorithms(&trustConfig, addr) callback := func(host string, remote net.Addr, key ssh.PublicKey) error { // SSH also invokes this callback during rekey, after the dial budget // has been released. Only the transport lifetime applies then. @@ -212,7 +213,7 @@ func (c *SSHClient) connectThrough(parent *ssh.Client) (err error) { close(stopped) }) sshConn, chans, reqs, handshakeErr := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{ - User: c.config.User, Auth: []ssh.AuthMethod{method}, HostKeyCallback: callback, + User: c.config.User, Auth: []ssh.AuthMethod{method}, HostKeyCallback: callback, HostKeyAlgorithms: algorithms, }) if !stop() { <-stopped diff --git a/internal/sshclient/exec.go b/internal/sshclient/exec.go index 25a349d..41af91b 100644 --- a/internal/sshclient/exec.go +++ b/internal/sshclient/exec.go @@ -25,12 +25,10 @@ func (c *SSHClient) runConfiguredCommand(capture, pty bool) (ExecResult, error) } } command := c.config.Command - var stdin io.Reader if c.config.SudoPassword != "" && CommandUsesSudo(command) { - command = sudoStdinCommand(command) - stdin = strings.NewReader(c.config.SudoPassword + "\n") + return c.executeSudo(command, nil, capture) } - return c.execute(command, stdin, capture, pty) + return c.execute(command, nil, capture, pty) } // RunScript streams a collector without installing it on the remote host. @@ -56,12 +54,10 @@ func (c *SSHClient) RunScriptWithShell(payload []byte, shell string, useSudo boo return ExecResult{ExitCode: -1}, boundaryError("auth", "prepare script", fmt.Errorf("sudo inspection requires a resolved sudo password")) } command := shell + " -s --" - var stdin io.Reader = bytes.NewReader(payload) if useSudo { - command = "sudo -S -p '' " + shell + " -s --" - stdin = io.MultiReader(strings.NewReader(c.config.SudoPassword+"\n"), bytes.NewReader(payload), strings.NewReader("\n")) + return c.executeSudo("sudo "+command, bytes.NewReader(payload), true) } - return c.execute(command, stdin, true, false) + return c.execute(command, bytes.NewReader(payload), true, false) } // RunCommandWithInput carries caller-prepared data and secrets over stdin. @@ -72,7 +68,16 @@ func (c *SSHClient) RunCommandWithInput(command string, stdin []byte) (ExecResul return c.execute(command, bytes.NewReader(stdin), true, false) } +// RunSudoCommandWithInput isolates the sudo conversation from application stdin. +func (c *SSHClient) RunSudoCommandWithInput(command string, stdin []byte) (ExecResult, error) { + return c.executeSudo(command, bytes.NewReader(stdin), true) +} + func (c *SSHClient) execute(command string, stdin io.Reader, capture, pty bool) (ExecResult, error) { + return c.executeSession(command, stdin, capture, pty, nil) +} + +func (c *SSHClient) executeSession(command string, stdin io.Reader, capture, pty bool, auth *sudoExchange) (ExecResult, error) { result := ExecResult{ExitCode: -1} conn, err := c.sshConnection() if err != nil { @@ -83,7 +88,27 @@ func (c *SSHClient) execute(command string, stdin io.Reader, capture, pty bool) return result, c.transportError("connect", "create session", err) } defer func() { _ = session.Close() }() //nolint:errcheck // best-effort session teardown - session.Stdin = stdin + var finishAuth func() + if auth == nil { + session.Stdin = stdin + } else { + input, pipeErr := session.StdinPipe() + if pipeErr != nil { + return result, pipeErr + } + auth.start(input) + stopped := false + finishAuth = func() { + if stopped { + return + } + stopped = true + close(auth.stop) + _ = session.Close() //nolint:errcheck // unblock the sender; only it owns stdin CloseWrite + <-auth.done + } + defer finishAuth() + } if pty { modes := ssh.TerminalModes{ssh.ECHO: 0, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400} if ptyErr := session.RequestPty("xterm", 80, 40, modes); ptyErr != nil { @@ -99,6 +124,11 @@ func (c *SSHClient) execute(command string, stdin io.Reader, capture, pty bool) } else { session.Stdout, session.Stderr = os.Stdout, os.Stderr } + var control *sudoControlWriter + if auth != nil { + control = &sudoControlWriter{dst: session.Stderr, auth: auth} + session.Stderr = control + } ctx := c.transportContext() var cancel context.CancelFunc if c.config.Timeout > 0 { @@ -129,6 +159,22 @@ func (c *SSHClient) execute(command string, stdin io.Reader, capture, pty bool) if !stop() { <-finished } + if finishAuth != nil { + finishAuth() + } + var sudoErr error + if control != nil { + if flushErr := control.flush(); err == nil { + err = flushErr + } + ready, authErr := auth.result() + if authErr != nil { + sudoErr = boundaryError("auth", "sudo handshake", authErr) + err = sudoErr + } else if err == nil && !ready { + err = boundaryError("auth", "sudo handshake", fmt.Errorf("sudo child readiness was not acknowledged")) + } + } if capture { result.Stdout, result.Stderr = stdout.String(), stderr.String() result.StdoutTruncated, result.StderrTruncated = stdout.Truncated(), stderr.Truncated() @@ -136,6 +182,9 @@ func (c *SSHClient) execute(command string, stdin io.Reader, capture, pty bool) // An observed exit remains evidence even if the outer budget expires while // final output is being drained. Never infer an exit from a local close. if cause := ctx.Err(); cause != nil { + if sudoErr != nil && (c.config.Context == nil || c.config.Context.Err() == nil) && !errors.Is(context.Cause(ctx), ErrCommandTimeout) { + return result, sudoErr + } if errors.Is(context.Cause(ctx), ErrCommandTimeout) { cause = errors.Join(ErrCommandTimeout, cause) } diff --git a/internal/sshclient/hostkey.go b/internal/sshclient/hostkey.go index 7e591ad..0842994 100644 --- a/internal/sshclient/hostkey.go +++ b/internal/sshclient/hostkey.go @@ -85,8 +85,14 @@ func getHostKeyCallback(cfg *Config) (ssh.HostKeyCallback, error) { return err } - // If there are known keys but they don't match, it's a key change if len(keyErr.Want) > 0 { + knownAlgorithm := false + for _, known := range keyErr.Want { + knownAlgorithm = knownAlgorithm || known.Key.Type() == key.Type() + } + if !knownAlgorithm { + return fmt.Errorf("host %s offered an unregistered host-key algorithm %s; verify this key independently or enable a previously trusted algorithm (keep existing known_hosts records): %w", hostname, key.Type(), err) + } return fmt.Errorf("⚠️ HOST KEY VERIFICATION FAILED!\n"+ "The host key for %s has changed.\n"+ "This could indicate a man-in-the-middle attack.\n"+ diff --git a/internal/sshclient/hostkey_algorithms.go b/internal/sshclient/hostkey_algorithms.go new file mode 100644 index 0000000..6d54736 --- /dev/null +++ b/internal/sshclient/hostkey_algorithms.go @@ -0,0 +1,73 @@ +package sshclient + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +// This is preference, not authorization: the normal callback still verifies +// the chosen key/certificate/revocation against the admitted trust material. +func preferredHostKeyAlgorithms(config *Config, address string) []string { + data := config.KnownHostsData + if data == nil { + filename := config.KnownHostsPath + if filename == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + filename = filepath.Join(home, ".ssh", "known_hosts") + } + var err error + data, err = os.ReadFile(filename) // #nosec G304 -- operator-selected public trust store + if err != nil { + return nil + } // #nosec G304 -- configured public trust store + } + callback, err := snapshotHostKeyCallback(data) + if err != nil { + return nil + } + err = callback(address, &net.TCPAddr{}, unmatchedHostKey{}) + var mismatch *knownhosts.KeyError + if !errors.As(err, &mismatch) || len(mismatch.Want) == 0 { + return nil + } + var algorithms []string + add := func(name string) { + for _, old := range algorithms { + if old == name { + return + } + } + algorithms = append(algorithms, name) + } + for _, key := range mismatch.Want { + switch key.Key.Type() { + case ssh.KeyAlgoRSA: + add(ssh.KeyAlgoRSASHA256) + add(ssh.KeyAlgoRSASHA512) + add(ssh.KeyAlgoRSA) + default: + add(key.Key.Type()) + } + } + // Preserve the dependency's existing supported set (including legacy keys + // already offered by its defaults). No algorithm bypasses host verification. + for _, name := range append(ssh.SupportedAlgorithms().HostKeys, ssh.InsecureAlgorithms().HostKeys...) { + add(name) + } + return algorithms +} + +type unmatchedHostKey struct{} + +func (unmatchedHostKey) Type() string { return "sshx-query-known-algorithms" } +func (unmatchedHostKey) Marshal() []byte { return []byte("sshx-query-known-algorithms") } +func (unmatchedHostKey) Verify([]byte, *ssh.Signature) error { return fmt.Errorf("not a signing key") } diff --git a/internal/sshclient/login.go b/internal/sshclient/login.go index 202c9c5..2964c92 100644 --- a/internal/sshclient/login.go +++ b/internal/sshclient/login.go @@ -16,10 +16,9 @@ var ( ) // PrivilegedLoginCommand is the remote program used by login --sudo. -// The sudo password is written to the session stdin ahead of the human TTY; -// it is never interpolated into argv. After authentication, echo is restored -// and a privileged login shell replaces the helper. -const PrivilegedLoginCommand = "sudo -S -p '' sh -c 'stty echo 2>/dev/null; exec sudo -i'" +// Authentication belongs to the human TTY. Never prefill a credential into a +// stream that may already be an interactive root shell (NOPASSWD/cache). +const PrivilegedLoginCommand = "sudo -i" // StdinIsTerminal reports whether stdin is an interactive terminal. func StdinIsTerminal() bool { diff --git a/internal/sshclient/login_test.go b/internal/sshclient/login_test.go index 92113ff..b330dd7 100644 --- a/internal/sshclient/login_test.go +++ b/internal/sshclient/login_test.go @@ -8,10 +8,10 @@ import ( "golang.org/x/crypto/ssh" ) -func TestPrivilegedLoginCommandUsesStdinSudo(t *testing.T) { +func TestPrivilegedLoginUsesHumanAuthentication(t *testing.T) { cmd := PrivilegedLoginCommand - if !strings.HasPrefix(cmd, "sudo -S -p ''") { - t.Fatalf("privileged login must use sudo -S stdin injection, got %q", cmd) + if cmd != "sudo -i" { + t.Fatalf("privileged login must leave authentication to the TTY, got %q", cmd) } if strings.Contains(cmd, "SudoPassword") || strings.Contains(cmd, "${") { t.Fatal("privileged login command must not interpolate secrets") diff --git a/internal/sshclient/login_unix.go b/internal/sshclient/login_unix.go index 012b84d..fb39f34 100644 --- a/internal/sshclient/login_unix.go +++ b/internal/sshclient/login_unix.go @@ -28,7 +28,7 @@ func (c *SSHClient) loginSession() error { fd := int(os.Stdin.Fd()) width, height := terminalSize(fd) - if ptyErr := session.RequestPty(loginTermName(), height, width, loginPtyModes(c.config.LoginUseSudo)); ptyErr != nil { + if ptyErr := session.RequestPty(loginTermName(), height, width, loginPtyModes(false)); ptyErr != nil { return fmt.Errorf("failed to request login PTY: %w", ptyErr) } forwardLoginEnv(session) @@ -47,15 +47,9 @@ func (c *SSHClient) loginSession() error { defer func() { _ = term.Restore(fd, state) }() //nolint:errcheck // always try to restore the TTY if c.config.LoginUseSudo { - if c.config.SudoPassword == "" { - return fmt.Errorf("login --sudo requires a resolved sudo password") - } if startErr := session.Start(PrivilegedLoginCommand); startErr != nil { return fmt.Errorf("failed to start privileged login shell: %w", startErr) } - if _, writeErr := io.WriteString(stdin, c.config.SudoPassword+"\n"); writeErr != nil { - return fmt.Errorf("failed to deliver sudo password: %w", writeErr) - } } else if startErr := session.Shell(); startErr != nil { return fmt.Errorf("failed to start login shell: %w", startErr) } diff --git a/internal/sshclient/runcommand_test.go b/internal/sshclient/runcommand_test.go index fecfc85..5bac9fe 100644 --- a/internal/sshclient/runcommand_test.go +++ b/internal/sshclient/runcommand_test.go @@ -106,6 +106,7 @@ func replyOK(req *ssh.Request) { } func runFakeCommand(ch ssh.Channel, command string) uint32 { + ch, command = unwrapSudoFixture(ch, command) switch command { case "exit0": writeAll(ch, "hello\n") @@ -279,7 +280,7 @@ func TestRunScriptKeepsSudoPasswordSeparateFromCollector(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, res.ExitCode) - assert.Equal(t, "sudo-payload:"+string(payload)+"\n", res.Stdout) + assert.Equal(t, "sudo-payload:"+string(payload), res.Stdout) assert.NotContains(t, res.Stdout, client.config.SudoPassword) } diff --git a/internal/sshclient/sftp_ops.go b/internal/sshclient/sftp_ops.go index 0b02f1e..fbf9649 100644 --- a/internal/sshclient/sftp_ops.go +++ b/internal/sshclient/sftp_ops.go @@ -186,10 +186,23 @@ func renderSFTPOutcome(w io.Writer, out *SFTPOutcome) error { } // ExecuteSftpResult returns evidence without writing human output. -func (c *SSHClient) ExecuteSftpResult() (out *SFTPOutcome, err error) { - out = &SFTPOutcome{Action: c.config.SftpAction, SourcePath: c.config.LocalPath, DestinationPath: c.config.RemotePath, Phase: "connect"} +func (c *SSHClient) ExecuteSftpResult() (*SFTPOutcome, error) { + return c.executeSftpResult(c.config) +} + +// CopyFile reuses the same publication and recovery contract for domain adapters. +// It never mutates the connection's Config or creates a second transport. +func (c *SSHClient) CopyFile(action, localPath, remotePath string) (*SFTPOutcome, error) { + config := *c.config + config.SftpAction, config.LocalPath, config.RemotePath = action, localPath, remotePath + config.PreparedPayload = nil + return c.executeSftpResult(&config) +} + +func (c *SSHClient) executeSftpResult(config *Config) (out *SFTPOutcome, err error) { + out = &SFTPOutcome{Action: config.SftpAction, SourcePath: config.LocalPath, DestinationPath: config.RemotePath, Phase: "connect"} if out.Action == "download" { - out.SourcePath, out.DestinationPath = c.config.RemotePath, c.config.LocalPath + out.SourcePath, out.DestinationPath = config.RemotePath, config.LocalPath } defer func() { if err != nil { @@ -210,16 +223,16 @@ func (c *SSHClient) ExecuteSftpResult() (out *SFTPOutcome, err error) { defer closeSFTP(client, &err) ctx := c.transportContext() out.Phase = "execute" - switch c.config.SftpAction { + switch config.SftpAction { case "upload": - entry := FileOutcome{SourcePath: c.config.LocalPath, Path: c.config.RemotePath, Type: "file", ChangeState: "unchanged", Verification: "not_performed"} + entry := FileOutcome{SourcePath: config.LocalPath, Path: config.RemotePath, Type: "file", ChangeState: "unchanged", Verification: "not_performed"} defer func() { out.Entries = append(out.Entries, entry) }() var source io.Reader - size := int64(len(c.config.PreparedPayload)) - if c.config.PreparedPayload != nil { - source = bytes.NewReader(bytes.Clone(c.config.PreparedPayload)) + size := int64(len(config.PreparedPayload)) + if config.PreparedPayload != nil { + source = bytes.NewReader(bytes.Clone(config.PreparedPayload)) } else { - file, openErr := os.Open(c.config.LocalPath) // #nosec G304 -- explicit local source + file, openErr := os.Open(config.LocalPath) // #nosec G304 -- explicit local source if openErr != nil { return out, boundaryError("local_io", "open source", openErr) } @@ -235,11 +248,11 @@ func (c *SSHClient) ExecuteSftpResult() (out *SFTPOutcome, err error) { } err = publishRemoteFile(ctx, client, source, "local_io", size, nil, &entry) case "download": - entry := FileOutcome{SourcePath: c.config.RemotePath, Path: c.config.LocalPath, Type: "file", ChangeState: "unchanged", Verification: "not_performed"} + entry := FileOutcome{SourcePath: config.RemotePath, Path: config.LocalPath, Type: "file", ChangeState: "unchanged", Verification: "not_performed"} defer func() { out.Entries = append(out.Entries, entry) }() err = downloadStaged(ctx, client, &entry) case "list", "ls": - remotePath := c.config.RemotePath + remotePath := config.RemotePath if remotePath == "" { remotePath = "." } @@ -253,11 +266,11 @@ func (c *SSHClient) ExecuteSftpResult() (out *SFTPOutcome, err error) { return out, listErr } case "mkdir": - entry := FileOutcome{Path: c.config.RemotePath, Type: "directory", ChangeState: "unchanged", Verification: "not_performed"} + entry := FileOutcome{Path: config.RemotePath, Type: "directory", ChangeState: "unchanged", Verification: "not_performed"} defer func() { out.Entries = append(out.Entries, entry) }() err = ensureRemoteDirectory(ctx, client, &entry) case "remove", "rm": - err = removeRemoteTree(ctx, client, c.config.RemotePath, out) + err = removeRemoteTree(ctx, client, config.RemotePath, out) } out.operationComplete = err == nil return out, err @@ -705,7 +718,7 @@ func (w boundaryWriter) Write(p []byte) (int, error) { return n, boundaryError(w.kind, "write copy destination", err) } func copyBoundary(ctx context.Context, dst io.Writer, src io.Reader, sourceKind, destinationKind string) (int64, error) { - return io.Copy(boundaryWriter{ctx, dst, destinationKind}, boundaryReader{ctx, src, sourceKind}) + return io.CopyBuffer(boundaryWriter{ctx, dst, destinationKind}, boundaryReader{ctx, src, sourceKind}, make([]byte, sftpReadApertureBytes)) } func remotePathJoin(elem ...string) string { return path.Join(elem...) } diff --git a/internal/sshclient/sftp_pipeline_test.go b/internal/sshclient/sftp_pipeline_test.go new file mode 100644 index 0000000..de27b2b --- /dev/null +++ b/internal/sshclient/sftp_pipeline_test.go @@ -0,0 +1,67 @@ +package sshclient + +import ( + "bytes" + "context" + "io" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/pkg/sftp" + "github.com/stretchr/testify/require" +) + +type delayedSFTPReader struct { + io.ReaderAt + active, peak *atomic.Int64 +} + +func (r delayedSFTPReader) ReadAt(p []byte, off int64) (int, error) { + n := r.active.Add(1) + defer r.active.Add(-1) + for { + old := r.peak.Load() + if n <= old || r.peak.CompareAndSwap(old, n) { + break + } + } + time.Sleep(5 * time.Millisecond) + return r.ReaderAt.ReadAt(p, off) +} + +type delayedSFTPGet struct { + sftp.FileReader + active, peak *atomic.Int64 +} + +func (r delayedSFTPGet) Fileread(req *sftp.Request) (io.ReaderAt, error) { + reader, err := r.FileReader.Fileread(req) + return delayedSFTPReader{reader, r.active, r.peak}, err +} + +func TestSFTPCopyBoundaryPipelinesReads(t *testing.T) { + handlers := sftp.InMemHandler() + file, err := handlers.FilePut.Filewrite(&sftp.Request{Method: "Put", Filepath: "/fixture", Flags: 26}) + require.NoError(t, err) + payload := bytes.Repeat([]byte("x"), 1<<20) + _, err = file.WriteAt(payload, 0) + require.NoError(t, err) + var active, peak atomic.Int64 + handlers.FileGet = delayedSFTPGet{handlers.FileGet, &active, &peak} + left, right := net.Pipe() + server := sftp.NewRequestServer(left, handlers) + go func() { _ = server.Serve() }() //nolint:errcheck // fixture disconnect + client, err := sftp.NewClientPipe(right, right, sftpClientOptions()...) + require.NoError(t, err) + defer func() { _ = client.Close(); _ = server.Close(); _ = right.Close() }() //nolint:errcheck // isolated fixture teardown + remote, err := client.Open("/fixture") + require.NoError(t, err) + var out bytes.Buffer + _, err = copyBoundary(context.Background(), &out, remote, "remote_io", "local_io") + require.NoError(t, err) + require.Equal(t, payload, out.Bytes()) + require.Greater(t, peak.Load(), int64(1), "copy wrappers must not collapse SFTP to one RTT per packet") + require.LessOrEqual(t, peak.Load(), int64(sftpMaxConcurrentRequests)) +} diff --git a/internal/sshclient/sftp_read.go b/internal/sshclient/sftp_read.go index e3c9b03..fa54a8f 100644 --- a/internal/sshclient/sftp_read.go +++ b/internal/sshclient/sftp_read.go @@ -39,6 +39,7 @@ const ( func sftpClientOptions() []sftp.ClientOption { return []sftp.ClientOption{ sftp.UseConcurrentReads(true), + sftp.UseConcurrentWrites(true), sftp.MaxConcurrentRequestsPerFile(sftpMaxConcurrentRequests), } } diff --git a/internal/sshclient/sftp_read_test.go b/internal/sshclient/sftp_read_test.go index 3461cdc..50435f3 100644 --- a/internal/sshclient/sftp_read_test.go +++ b/internal/sshclient/sftp_read_test.go @@ -177,8 +177,8 @@ func (r *eofWithDataReader) Read(p []byte) (int, error) { // the fix: the options that keep pkg/sftp overlapping reads must stay wired. func TestSFTPClientOptionsEnablePipelining(t *testing.T) { opts := sftpClientOptions() - if len(opts) != 2 { - t.Fatalf("sftpClientOptions returned %d options, want 2 (UseConcurrentReads + MaxConcurrentRequestsPerFile)", len(opts)) + if len(opts) != 3 { + t.Fatalf("sftpClientOptions returned %d options, want 3 (concurrent reads/writes + bounded window)", len(opts)) } if sftpMaxConcurrentRequests <= 1 { t.Fatalf("sftpMaxConcurrentRequests = %d, want a pipelining window", sftpMaxConcurrentRequests) diff --git a/internal/sshclient/sudo.go b/internal/sshclient/sudo.go new file mode 100644 index 0000000..6601a5a --- /dev/null +++ b/internal/sshclient/sudo.go @@ -0,0 +1,266 @@ +package sshclient + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "strings" + "sync" + "time" +) + +// sudoExchange gates two different consumers of SSH stdin. A credential is sent +// only after sudo's nonce-bound prompt; application data only after the child +// acknowledges readiness. NOPASSWD/cache/root never receive a credential. +// No secret file, environment variable, extra remote fd or PTY is required. +type sudoExchange struct { + prompt, ready, password string + payload io.Reader + events chan bool // false: prompt, true: child ready + stop chan struct{} + done chan struct{} + mu sync.Mutex + err error + childReady bool + abort func() +} + +func (c *SSHClient) executeSudo(command string, payload io.Reader, capture bool) (ExecResult, error) { + password := c.config.SudoPassword + if strings.ContainsAny(password, "\x00\r\n") { + return ExecResult{ExitCode: -1}, boundaryError("auth", "prepare sudo", fmt.Errorf("sudo password must be a single line")) + } + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return ExecResult{ExitCode: -1}, err + } + id := hex.EncodeToString(nonce[:]) + auth := &sudoExchange{prompt: "SSHX_SUDO_PROMPT_" + id, ready: "SSHX_SUDO_READY_" + id, password: password, payload: payload, events: make(chan bool, 2), stop: make(chan struct{}), done: make(chan struct{}), abort: func() { _ = c.Close() }} //nolint:errcheck // abort owns the transport + wrapped, err := sudoReadyCommand(command, auth.prompt, auth.ready) + if err != nil { + return ExecResult{ExitCode: -1}, boundaryError("config", "prepare sudo", err) + } + return c.executeSession(wrapped, nil, capture, false, auth) +} + +func (s *sudoExchange) failure(err error) { + s.mu.Lock() + if s.err == nil { + s.err = err + } + s.mu.Unlock() + s.abort() +} +func (s *sudoExchange) result() (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.childReady, s.err +} +func (s *sudoExchange) start(w io.WriteCloser) { + go func() { + defer close(s.done) + defer func() { _ = w.Close() }() //nolint:errcheck // EOF is best-effort after send/abort + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + sent := false + for { + select { + case <-s.stop: + return + case <-timer.C: + s.failure(fmt.Errorf("sudo readiness handshake timed out")) + return + case ready := <-s.events: + if !ready { + if sent || s.password == "" { + s.failure(fmt.Errorf("sudo authentication was rejected or no credential is available")) + return + } + sent = true + if _, err := io.WriteString(w, s.password+"\n"); err != nil { + s.failure(fmt.Errorf("send sudo authentication: %w", err)) + return + } + continue + } + timer.Stop() + s.mu.Lock() + s.childReady = true + s.mu.Unlock() + if s.payload != nil { + if _, err := io.Copy(w, s.payload); err != nil { + s.failure(fmt.Errorf("send sudo payload: %w", err)) + } + } + return + } + } + }() +} + +// sudoControlWriter strips only session-owned framing, including markers split +// across SSH packets. After ready it is a transparent stderr writer. +type sudoControlWriter struct { + dst io.Writer + auth *sudoExchange + pending []byte + prompted, ready bool +} + +func (w *sudoControlWriter) Write(p []byte) (int, error) { + n := len(p) + if w.ready { + return w.dst.Write(p) + } + w.pending = append(w.pending, p...) + for len(w.pending) > 0 { + a, b := bytes.Index(w.pending, []byte(w.auth.prompt)), bytes.Index(w.pending, []byte(w.auth.ready)) + pos, ready := a, false + if b >= 0 && (a < 0 || b < a) { + pos, ready = b, true + } + if pos >= 0 { + if _, err := w.dst.Write(w.pending[:pos]); err != nil { + return 0, err + } + marker := w.auth.prompt + if ready { + marker = w.auth.ready + } + w.pending = w.pending[pos+len(marker):] + if !ready && w.prompted { + w.auth.failure(fmt.Errorf("repeated sudo authentication prompt")) + return 0, fmt.Errorf("sudo authentication rejected") + } + w.prompted = w.prompted || !ready + if ready { + w.auth.mu.Lock() + w.auth.childReady = true + w.auth.mu.Unlock() + } + w.auth.events <- ready + if ready { + w.ready = true + _, err := w.dst.Write(w.pending) + w.pending = nil + return n, err + } + continue + } + keep := 0 + for _, marker := range []string{w.auth.prompt, w.auth.ready} { + for k := 1; k < len(marker) && k <= len(w.pending); k++ { + if bytes.Equal(w.pending[len(w.pending)-k:], []byte(marker[:k])) && k > keep { + keep = k + } + } + } + if _, err := w.dst.Write(w.pending[:len(w.pending)-keep]); err != nil { + return 0, err + } + w.pending = append([]byte(nil), w.pending[len(w.pending)-keep:]...) + break + } + return n, nil +} +func (w *sudoControlWriter) flush() error { + _, err := w.dst.Write(w.pending) + w.pending = nil + return err +} + +// Preserve the original command's quoting, expansions, pipes and redirections: +// the wrapper executes argv, not a second parse of the user's command text. +// Only the leading sudo options are parsed. Ambiguous shell/login modes fail +// closed rather than accidentally changing the effective privilege boundary. +func sudoReadyCommand(command, prompt, ready string) (string, error) { + rest, ok := leadingSudoRemainder(command) + if !ok { + return "", fmt.Errorf("expected leading sudo") + } + var options []string + for strings.HasPrefix(strings.TrimSpace(rest), "-") { + token, tail, err := sudoWord(rest) + if err != nil { + return "", err + } + rest = tail + if token == "--" { + break + } + switch token { + case "-S", "--stdin": + continue + case "-p", "--prompt": + _, rest, err = sudoWord(rest) + if err != nil { + return "", err + } + continue + case "-u", "--user", "-g", "--group", "-h", "--host", "-D", "--chdir": + value, next, e := sudoWord(rest) + if e != nil { + return "", e + } + options = append(options, token, value) + rest = next + case "-n", "--non-interactive", "-H", "--set-home", "-E", "--preserve-env", "-k", "--reset-timestamp": + options = append(options, token) + default: + if strings.HasPrefix(token, "--prompt=") { + continue + } + if strings.HasPrefix(token, "--user=") || strings.HasPrefix(token, "--group=") || strings.HasPrefix(token, "--preserve-env=") || strings.HasPrefix(token, "-u") && len(token) > 2 || strings.HasPrefix(token, "-g") && len(token) > 2 { + options = append(options, token) + } else { + return "", fmt.Errorf("sudo option %q is not supported for credential injection; use an explicit sudo command without shell/login mode", token) + } + } + } + rest = strings.TrimSpace(rest) + if rest == "" { + return "", fmt.Errorf("sudo requires an explicit noninteractive command") + } + return "sudo " + strings.Join(options, " ") + " -S -p '" + prompt + "' -- sh -c 'printf %s " + ready + " >&2; exec \"$@\"' sh " + rest, nil +} + +// Return one shell word verbatim, retaining quoted option values. Expansions +// and shell control operators are not accepted in the sudo option prefix. +func sudoWord(s string) (string, string, error) { + s = strings.TrimSpace(s) + quote := byte(0) + escaped := false + for i := 0; i < len(s); i++ { + ch := s[i] + if escaped { + escaped = false + continue + } + if ch == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if ch == quote { + quote = 0 + } + continue + } + if ch == '\'' || ch == '"' { + quote = ch + continue + } + if strings.ContainsRune("$`;|&<>()\r\n", rune(ch)) { + return "", "", fmt.Errorf("unsupported shell syntax in sudo option") + } + if ch == ' ' || ch == '\t' { + return s[:i], s[i+1:], nil + } + } + if s == "" || quote != 0 || escaped { + return "", "", fmt.Errorf("missing or malformed sudo option value") + } + return s, "", nil +} diff --git a/internal/sshclient/sudo_fixture_test.go b/internal/sshclient/sudo_fixture_test.go new file mode 100644 index 0000000..334a739 --- /dev/null +++ b/internal/sshclient/sudo_fixture_test.go @@ -0,0 +1,37 @@ +package sshclient + +import ( + "bufio" + "io" + "regexp" + "strings" + + "golang.org/x/crypto/ssh" +) + +type sudoFixtureChannel struct { + ssh.Channel + input io.Reader +} + +func (c sudoFixtureChannel) Read(p []byte) (int, error) { return c.input.Read(p) } + +// Keep existing domain fixtures focused on their action, while honoring the +// authentication/readiness transport protocol. Dedicated boundary tests also +// execute actual shells without this compatibility adapter. +func unwrapSudoFixture(ch ssh.Channel, command string) (ssh.Channel, string) { + prompt := regexp.MustCompile(`SSHX_SUDO_PROMPT_[a-f0-9]+`).FindString(command) + ready := regexp.MustCompile(`SSHX_SUDO_READY_[a-f0-9]+`).FindString(command) + _, suffix, ok := strings.Cut(command, `' sh `) + if prompt == "" || ready == "" || !ok { + return ch, command + } + writeAll(ch.Stderr(), prompt) + reader := bufio.NewReader(ch) + password, err := reader.ReadString('\n') + if err != nil { + return ch, "fixture-auth-failed" + } + writeAll(ch.Stderr(), ready) + return sudoFixtureChannel{ch, io.MultiReader(strings.NewReader(password), reader)}, "sudo -S -p '' " + suffix +} diff --git a/internal/sshclient/sudo_test.go b/internal/sshclient/sudo_test.go new file mode 100644 index 0000000..26601bc --- /dev/null +++ b/internal/sshclient/sudo_test.go @@ -0,0 +1,55 @@ +package sshclient + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSudoFramingAcrossEveryPacketBoundary(t *testing.T) { + const prompt = "SSHX_SUDO_PROMPT_fixture" + const ready = "SSHX_SUDO_READY_fixture" + text := "notice:" + prompt + "authenticated:" + ready + "application stderr" + for split := 0; split <= len(text); split++ { + exchange := &sudoExchange{prompt: prompt, ready: ready, events: make(chan bool, 2), abort: func() { t.Fatal("unexpected protocol rejection") }} + var out bytes.Buffer + writer := &sudoControlWriter{dst: &out, auth: exchange} + _, err := writer.Write([]byte(text[:split])) + require.NoError(t, err) + _, err = writer.Write([]byte(text[split:])) + require.NoError(t, err) + require.NoError(t, writer.flush()) + assert.Equal(t, "notice:authenticated:application stderr", out.String()) + assert.False(t, <-exchange.events) + assert.True(t, <-exchange.events) + } +} + +func TestSudoNoPromptDoesNotRequestCredential(t *testing.T) { + exchange := &sudoExchange{prompt: "PROMPT", ready: "READY", events: make(chan bool, 2), abort: func() { t.Fatal("unexpected abort") }} + var out bytes.Buffer + writer := &sudoControlWriter{dst: &out, auth: exchange} + _, err := writer.Write([]byte("READYpayload")) + require.NoError(t, err) + require.Len(t, exchange.events, 1) + assert.True(t, <-exchange.events) + assert.Equal(t, "payload", out.String()) +} + +func TestSudoCommandKeepsShellBoundary(t *testing.T) { + for _, cmd := range []string{`sudo cat`, `sudo -u root -n sh -c 'printf x' | grep x`, `sudo -H echo "a b" > /tmp/file`, `sudo --user=root -- cat`} { + wrapped, err := sudoReadyCommand(cmd, "PROMPT", "READY") + require.NoError(t, err) + assert.Contains(t, wrapped, `exec "$@"`) + if strings.Contains(cmd, " | grep x") { + assert.True(t, strings.HasSuffix(wrapped, `sh -c 'printf x' | grep x`)) + } + } + for _, cmd := range []string{"sudo", "sudo -i", "sudo -s sh", "sudo -u", "sudo -u $(whoami) id"} { + _, err := sudoReadyCommand(cmd, "PROMPT", "READY") + require.Error(t, err, cmd) + } +} diff --git a/internal/sshclient/transport_reliability_test.go b/internal/sshclient/transport_reliability_test.go index d0e3edb..bb5f5be 100644 --- a/internal/sshclient/transport_reliability_test.go +++ b/internal/sshclient/transport_reliability_test.go @@ -214,6 +214,11 @@ func TestTransportCancellationBoundaries(t *testing.T) { phase == "sftp_version" && request.Type == "subsystem": replyOK(request) if phase == "wait" { + var payload struct{ Command string } + if err := ssh.Unmarshal(request.Payload, &payload); err != nil { + return + } + channel, _ = unwrapSudoFixture(channel, payload.Command) var first [1]byte if _, err := io.ReadFull(channel, first[:]); err != nil { return diff --git a/plugin.json b/plugin.json index 5248e3f..b2f1487 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sshx", - "version": "0.19.0", + "version": "0.20.0", "description": "Agent-oriented remote host execution over SSH — JSON results, dry-run plans, safety rails, audit trail, MCP server, and an installable Agent skill.", "author": { "name": "talkincode", diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index 9385f8e..dcd41bb 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -86,8 +86,9 @@ interpret `partial` or permission errors as application absence. ## Do not use `sshx login` from an agent `sshx login` attaches a human TTY to a remote shell (optional `--sudo` privileged -login). It has no Agent JSON contract, is rejected without a TTY, and is not an -MCP tool. For programmatic work keep using `sshx run` / `sshx inspect` / +login). Sudo authentication is handled by that human TTY; no sudo keyring secret +is read or prefilled. It has no Agent JSON contract, is rejected without a TTY, +and is not an MCP tool. For programmatic work keep using `sshx run` / `sshx inspect` / `sshx apply` / `sshx sql` / `sshx text` with `--json`. ## Dissect logs before downloading them @@ -102,6 +103,23 @@ sshx text -h=prod-web --journal=nginx.service --since='1h' --preset=error --json Do not invent `grep`/`awk`/`journalctl` pipelines under `sshx run` for triage. There is no `--command` on `sshx text`. Download whole files only for archives. +## Sudo input safety (v0.20+) + +Noninteractive auto-fill and privileged run/apply/inspect/sql/text use a +nonce-bound prompt/readiness handshake. No prompt means no credential is sent; +application input is withheld until the child is ready. Do not prepend a sudo +password to a script or database input stream. Ambiguous sudo `-s`/`-i` options +and multiline passwords are rejected; use the human-only login for a login shell. +The readiness wrapper needs sudo permission for a shell. Do not bypass a +command-only sudoers restriction or redirect away the control stderr. +Re-preview any saved plan after upgrade (execution semantics v2). + +`sshx ros raw` now preserves the supplied text, including quoting and option +order; add `without-paging` explicitly when needed. Device diagnostic failures +are surfaced even with an SSH exit status of zero. ROS remains unbindable with +`--expect-plan`; it is not a sandbox. Credential-shaped audit redaction remains +best-effort, not a guarantee that arbitrary output is safe to publish. + ## Golden rule for agents: prefer `sshx run` + `--json`/`--jsonl` For any non-interactive/programmatic use, prefer the canonical run contract: @@ -510,6 +528,9 @@ sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ - `--expect-sha256` is an optional hash precondition, not arbitrary-writer CAS. A pre-commit mismatch is `error_kind: "precondition"` with no target write. - Backups default to `~/.sshx/file-backups/`. `--no-backup` requires `--force`. +- `--sudo --stage-dir=/absolute/private/path` selects a separate SFTP staging + filesystem when the remote home disk is full. It is included in the plan hash; + it does not change backup placement or final same-filesystem atomic publication. - `--sudo` stages the payload over SFTP, then installs with a privileged stdin script. Use it when the SSH user cannot write the target. - `/etc/passwd`, `/etc/shadow`, and `/etc/sudoers` require diff --git a/tests/e2e/apply_stage_e2e_test.go b/tests/e2e/apply_stage_e2e_test.go new file mode 100644 index 0000000..f19cfec --- /dev/null +++ b/tests/e2e/apply_stage_e2e_test.go @@ -0,0 +1,48 @@ +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyExplicitStageDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX privileged apply fixture") + } + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + env := map[string]string{"SSHX_E2E_KEYRING_FILE": filepath.Join(home, "keyring.json"), "SSH_PASSWORD": operatorPassword} + set := runSSHXWithTestKeyring(t, home, []string{"--password-set=fixture:" + operatorPassword, "--no-audit"}, env) + require.Equal(t, 0, set.exitCode, set.stderr) + source := filepath.Join(home, "source") + require.NoError(t, os.WriteFile(source, []byte("stage-fixture"), 0600)) + target := filepath.Join(server.root, "target") + stage := filepath.Join(server.root, "data-stage") + args := []string{"apply", "-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "-pk=fixture", "--sudo", "--path=" + target, "--from=" + source, "--stage-dir=" + stage, "--json"} + preview := runSSHXWithTestKeyring(t, home, append(append([]string{}, args...), "--dry-run"), env) + require.Equal(t, 0, preview.exitCode, preview.stdout) + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(preview.stdout), &doc)) + assert.Contains(t, preview.stdout, stage) + other := append([]string{}, args...) + other[len(other)-2] = "--stage-dir=" + stage + "-other" + changed := runSSHXWithTestKeyring(t, home, append(other, "--dry-run"), env) + require.Equal(t, 0, changed.exitCode, changed.stdout) + var next map[string]any + require.NoError(t, json.Unmarshal([]byte(changed.stdout), &next)) + assert.NotEqual(t, doc["plan_hash"], next["plan_hash"]) + result := runSSHXWithTestKeyring(t, home, args, env) + require.Equal(t, 0, result.exitCode, "stdout=%s stderr=%s", result.stdout, result.stderr) + data, err := os.ReadFile(target) // #nosec G304 -- isolated fixture target + require.NoError(t, err) + assert.Equal(t, "stage-fixture", string(data)) + entries, err := os.ReadDir(stage) + require.NoError(t, err) + assert.Empty(t, entries) +} diff --git a/tests/e2e/apply_stage_mcp_e2e_test.go b/tests/e2e/apply_stage_mcp_e2e_test.go new file mode 100644 index 0000000..2b68857 --- /dev/null +++ b/tests/e2e/apply_stage_mcp_e2e_test.go @@ -0,0 +1,16 @@ +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMCPApplyStagePreview(t *testing.T) { + client := startMCPClient(t, t.TempDir(), nil) + failed, output := client.callTool("sshx_apply", map[string]any{"target": "192.0.2.1", "path": "/tmp/fixture", "content": "fixture", "sudo": true, "stage_dir": "/data/private-stage", "dry_run": true}) + assert.False(t, failed, output) + assert.Contains(t, output, "/data/private-stage") + failed, output = client.callTool("sshx_apply", map[string]any{"target": "192.0.2.1", "path": "/tmp/fixture", "content": "fixture", "stage_dir": "/data/private-stage", "dry_run": true}) + assert.True(t, failed, output) +} diff --git a/tests/e2e/audit_boundaries_e2e_test.go b/tests/e2e/audit_boundaries_e2e_test.go new file mode 100644 index 0000000..f91bacd --- /dev/null +++ b/tests/e2e/audit_boundaries_e2e_test.go @@ -0,0 +1,67 @@ +package e2e + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +func TestAuditRedactsNamedTokensBeforeAdmission(t *testing.T) { + home := t.TempDir() + result := runSSHX(t, home, []string{"run", "--address=127.0.0.1", "--json", "--", "rm -rf /; tool --reverse-token FIXTURE_TOKEN Authorization=FIXTURE_HEADER"}, map[string]string{"SSHX_NO_AUDIT": "false"}) + require.Equal(t, 255, result.exitCode) + files, err := filepath.Glob(filepath.Join(home, ".sshx", "audit", "*.jsonl")) + require.NoError(t, err) + require.NotEmpty(t, files) + for _, p := range files { + data, err := os.ReadFile(p) // #nosec G304 -- isolated fixture audit directory + require.NoError(t, err) + assert.NotContains(t, string(data), "FIXTURE_TOKEN") + assert.NotContains(t, string(data), "FIXTURE_HEADER") + } + assert.NotContains(t, result.stdout, "FIXTURE_TOKEN") +} + +func TestHostDiagnosticsHonorsDeadline(t *testing.T) { + signer := newHostSigner(t) + server := startSSHServer(t, serverOptions{hostSigner: signer, execHandler: func(ch ssh.Channel, _, _ string) { time.Sleep(1500 * time.Millisecond); sendExitStatus(ch, 0) }}) + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, ".ssh"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".ssh", "known_hosts"), []byte(knownhosts.Line([]string{net.JoinHostPort(server.host, server.port)}, signer.PublicKey())+"\n"), 0600)) + settings := map[string]any{"hosts": []any{map[string]any{"name": "fixture", "host": server.host, "port": server.port, "user": "operator"}}} + data, err := json.Marshal(settings) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".sshx"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".sshx", "settings.json"), data, 0600)) + start := time.Now() + result := runSSHX(t, home, []string{"--host-test=fixture", "--no-key", "--timeout=100ms", "--global-timeout=100ms", "--json"}, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 255, result.exitCode, result.stdout) + assert.Less(t, time.Since(start), time.Second) + assert.Contains(t, result.stdout, `"error_kind":"timeout"`) +} + +func TestHostKeyPrefersKnownAlgorithm(t *testing.T) { + ed := newHostSigner(t) + private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + ec, err := ssh.NewSignerFromKey(private) + require.NoError(t, err) + server := startSSHServer(t, serverOptions{hostSigner: ed, extraHostSigners: []ssh.Signer{ec}}) + home := t.TempDir() + trust := filepath.Join(home, "known_hosts") + require.NoError(t, os.WriteFile(trust, []byte(knownhosts.Line([]string{net.JoinHostPort(server.host, server.port)}, ed.PublicKey())+"\n"), 0600)) + result := runSSHX(t, home, []string{"-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--known-hosts=" + trust, "--json", "probe"}, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, result.exitCode, result.stdout) + assert.Contains(t, result.stdout, "probe-ok") +} diff --git a/tests/e2e/cli_e2e_test.go b/tests/e2e/cli_e2e_test.go index e8d4fe4..b90c7a0 100644 --- a/tests/e2e/cli_e2e_test.go +++ b/tests/e2e/cli_e2e_test.go @@ -316,9 +316,9 @@ func TestCLILoginDryRunAndHumanGates(t *testing.T) { assert.Equal(t, "10.9.8.7", plan.HostResolved) assert.True(t, plan.UsesSudo) assert.True(t, plan.WouldConnect) - assert.True(t, plan.WouldReadSecret) + assert.False(t, plan.WouldReadSecret) assert.True(t, plan.WouldMutateRemote) - assert.Contains(t, plan.Command, "sudo -S") + assert.Equal(t, "sudo -i", plan.Command) jsonOnly := runSSHX(t, home, []string{"login", "--target=prod-web", "--json"}, nil) require.Equal(t, 255, jsonOnly.exitCode, jsonOnly.stderr) diff --git a/tests/e2e/harness_test.go b/tests/e2e/harness_test.go index 2b68cd4..72d7a6b 100644 --- a/tests/e2e/harness_test.go +++ b/tests/e2e/harness_test.go @@ -65,6 +65,7 @@ type serverOptions struct { authorizedKey ssh.PublicKey reportedUID string hostSigner ssh.Signer + extraHostSigners []ssh.Signer execHandler func(ssh.Channel, string, string) operatorPassword string readerPassword string @@ -178,6 +179,9 @@ func startSSHServer(t *testing.T, options serverOptions) *testSSHServer { }, } config.AddHostKey(signer) + for _, extra := range options.extraHostSigners { + config.AddHostKey(extra) + } listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -343,6 +347,7 @@ func handleSSHSession(channel ssh.Channel, requests <-chan *ssh.Request, server return } + channel, payload.Command = unwrapSudoFixture(channel, payload.Command) exitCode := uint32(0) switch { case isCollectorCommand(payload.Command) != "": diff --git a/tests/e2e/ros_boundary_e2e_test.go b/tests/e2e/ros_boundary_e2e_test.go new file mode 100644 index 0000000..1c10349 --- /dev/null +++ b/tests/e2e/ros_boundary_e2e_test.go @@ -0,0 +1,68 @@ +package e2e + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestROSExecutionBoundaries(t *testing.T) { + t.Run("raw bytes and audit", func(t *testing.T) { + wire := make(chan string, 1) + server := startSSHServer(t, serverOptions{execHandler: func(ch ssh.Channel, command, _ string) { wire <- command; sendExitStatus(ch, 0) }}) + home := t.TempDir() + cmd := `/container/add name=fixture cmd="--config /etc/rtp/config.toml --name demo"` + result := runSSHX(t, home, []string{"ros", "-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "--allow-write", "--json", "raw", cmd}, map[string]string{"SSH_PASSWORD": operatorPassword, "SSHX_NO_AUDIT": "false"}) + require.Equal(t, 0, result.exitCode, result.stdout) + assert.Equal(t, cmd, <-wire) + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(result.stdout), &doc)) + assert.Equal(t, "written", doc["audit_status"]) + files, err := filepath.Glob(filepath.Join(home, ".sshx", "audit", "*.jsonl")) + require.NoError(t, err) + require.Len(t, files, 1) + b, err := os.ReadFile(files[0]) + require.NoError(t, err) + var audit map[string]any + require.NoError(t, json.Unmarshal(b, &audit)) + assert.NotEmpty(t, audit["action"]) + assert.NotEmpty(t, audit["command"]) + assert.Equal(t, true, audit["would_mutate_remote"]) + assert.Equal(t, doc["execution_fingerprint"], audit["execution_fingerprint"]) + }) + t.Run("device error with exit zero", func(t *testing.T) { + server := startSSHServer(t, serverOptions{execHandler: func(ch ssh.Channel, _, _ string) { + _, _ = io.WriteString(ch, "input does not match any value of value-name\n") //nolint:errcheck // fixture peer may disconnect + sendExitStatus(ch, 0) + }}) + result := runSSHX(t, t.TempDir(), []string{"ros", "-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "--json", "raw", "/container/print proplist=name,status"}, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 255, result.exitCode, result.stdout) + assert.Contains(t, result.stdout, `"success":false`) + assert.Contains(t, result.stdout, "ros_command_error") + }) + t.Run("download preserves symlink target", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX symlink fixture") + } + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + source := filepath.Join(server.root, "source") + victim := filepath.Join(home, "victim") + dest := filepath.Join(home, "link") + require.NoError(t, os.WriteFile(source, []byte("new"), 0600)) + require.NoError(t, os.WriteFile(victim, []byte("old"), 0600)) + require.NoError(t, os.Symlink(victim, dest)) + result := runSSHX(t, home, []string{"ros", "-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "--json", "file", "download", source, dest}, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 255, result.exitCode, result.stdout) + b, err := os.ReadFile(victim) // #nosec G304 -- isolated fixture target + require.NoError(t, err) + assert.Equal(t, "old", string(b)) + }) +} diff --git a/tests/e2e/ros_e2e_test.go b/tests/e2e/ros_e2e_test.go index 0143307..84e23ea 100644 --- a/tests/e2e/ros_e2e_test.go +++ b/tests/e2e/ros_e2e_test.go @@ -111,10 +111,8 @@ func TestROSE2E_DryRun(t *testing.T) { var planRawWrite map[string]any require.NoError(t, json.Unmarshal([]byte(resRawWrite.stdout), &planRawWrite)) assert.Equal(t, true, planRawWrite["will_modify_routeros"]) //nolint:misspell // RouterOS domain key - resolvedArgs, ok := planRawWrite["resolved_args"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "10.0.0.1/24", resolvedArgs["address"]) - assert.Equal(t, "ether2", resolvedArgs["interface"]) + assert.Empty(t, planRawWrite["resolved_args"]) + assert.Equal(t, "/ip/address/add address=10.0.0.1/24 interface=ether2", planRawWrite["raw_command"]) // 4. Workflow dry-run resWf := runSSHX(t, home, []string{ @@ -265,7 +263,7 @@ func TestROSE2E_LiveWorkflows(t *testing.T) { case strings.Contains(cmd, "backup") && strings.Contains(cmd, "save"): name := "test_backup" if idx := strings.Index(cmd, "name="); idx != -1 { - name = strings.Fields(cmd[idx+5:])[0] + name = strings.Trim(strings.Fields(cmd[idx+5:])[0], `"`) } backupFile := filepath.Join(serverRoot, name+".backup") _ = os.WriteFile(backupFile, []byte("ROUTEROS-BINARY-BACKUP-PAYLOAD"), 0o600) //nolint:errcheck,misspell // fixture backup file diff --git a/tests/e2e/ros_preview_e2e_test.go b/tests/e2e/ros_preview_e2e_test.go new file mode 100644 index 0000000..7831723 --- /dev/null +++ b/tests/e2e/ros_preview_e2e_test.go @@ -0,0 +1,20 @@ +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestROSPreviewIsOfflineAndConservative(t *testing.T) { + server := startSSHServer(t, serverOptions{execHandler: func(ch ssh.Channel, _, _ string) { sendExitStatus(ch, 0) }}) + home := t.TempDir() + result := runSSHX(t, home, []string{"ros", "-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "--dry-run", "--json", "doctor", "--include-remote"}, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, result.exitCode, result.stdout) + assert.Zero(t, server.connections.Load()) + result = runSSHX(t, home, []string{"ros", "--dry-run", "--json", "raw", "/interface/print ; /ip/address/remove numbers=99"}, nil) + require.Equal(t, 0, result.exitCode, result.stdout) + assert.Contains(t, result.stdout, `"will_modify_routeros": true`) +} diff --git a/tests/e2e/sudo_boundary_e2e_test.go b/tests/e2e/sudo_boundary_e2e_test.go new file mode 100644 index 0000000..fc96cfa --- /dev/null +++ b/tests/e2e/sudo_boundary_e2e_test.go @@ -0,0 +1,75 @@ +package e2e + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +// Exercise the compiled CLI against real shells. Unlike the historical SSH +// fixture, sudo is allowed not to consume stdin (NOPASSWD/cached/root). +func TestSudoInputBoundary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX sudo/shell input boundary") + } + for _, mode := range []string{"password", "cached"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "bin") + require.NoError(t, os.Mkdir(bin, 0700)) + shim := `#!/bin/sh +prompt= +while [ "$#" -gt 0 ]; do + case "$1" in + -S) shift;; + -p) prompt=$2; shift 2;; + --) shift; break;; + *) break;; + esac +done +if [ "$FIXTURE_MODE" = password ]; then + printf '%s' "$prompt" >&2 + IFS= read -r password || exit 1 + [ "$password" = SSHX_PRIVATE_CANARY ] || exit 1 +fi +exec "$@" +` + require.NoError(t, os.WriteFile(filepath.Join(bin, "sudo"), []byte(shim), 0700)) // #nosec G306 -- executable isolated fixture + server := startSSHServer(t, serverOptions{root: root, execHandler: func(ch ssh.Channel, command, _ string) { + cmd := exec.Command("/bin/sh", "-c", command) // #nosec G204 -- isolated fixture commands only. + cmd.Dir = root + cmd.Env = []string{"PATH=" + bin + ":/usr/bin:/bin", "FIXTURE_MODE=" + mode} + cmd.Stdin = ch + cmd.Stdout = ch + cmd.Stderr = ch.Stderr() + code := uint32(0) + if err := cmd.Run(); err != nil { + code = 1 + } + sendExitStatus(ch, code) + }}) + home := t.TempDir() + env := map[string]string{"SSHX_E2E_KEYRING_FILE": filepath.Join(home, "keyring.json"), "SSH_PASSWORD": operatorPassword} + set := runSSHXWithTestKeyring(t, home, []string{"--password-set=fixture:SSHX_PRIVATE_CANARY", "--no-audit"}, env) + require.Equal(t, 0, set.exitCode, set.stderr) + script := filepath.Join(home, "script.sh") + require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\nprintf 'payload-ok\\n'\n"), 0600)) + args := []string{"run", "--address=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "--bypass-reason=isolated-fixture", "-pk=fixture", "--sudo", "--script-file=" + script, "--timeout=3s", "--json"} + result := runSSHXWithTestKeyring(t, home, args, env) + require.Equal(t, 0, result.exitCode, "stdout=%s stderr=%s", result.stdout, result.stderr) + assert.Contains(t, result.stdout, "payload-ok") + assert.NotContains(t, result.stdout, "SSHX_PRIVATE_CANARY") + assert.NotContains(t, result.stderr, "SSHX_PRIVATE_CANARY") + // A stdin-reading child must receive EOF, not an unsolicited credential. + result = runSSHXWithTestKeyring(t, home, []string{"-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "-pk=fixture", "--timeout=3s", "--json", "sudo cat"}, env) + require.Equal(t, 0, result.exitCode, "stdout=%s stderr=%s", result.stdout, result.stderr) + assert.NotContains(t, result.stdout, "SSHX_PRIVATE_CANARY") + }) + } +} diff --git a/tests/e2e/sudo_fixture_test.go b/tests/e2e/sudo_fixture_test.go new file mode 100644 index 0000000..abc5ae5 --- /dev/null +++ b/tests/e2e/sudo_fixture_test.go @@ -0,0 +1,34 @@ +package e2e + +import ( + "bufio" + "io" + "regexp" + "strings" + + "golang.org/x/crypto/ssh" +) + +type sudoFixtureChannel struct { + ssh.Channel + input io.Reader +} + +func (c sudoFixtureChannel) Read(p []byte) (int, error) { return c.input.Read(p) } + +func unwrapSudoFixture(ch ssh.Channel, command string) (ssh.Channel, string) { + prompt := regexp.MustCompile(`SSHX_SUDO_PROMPT_[a-f0-9]+`).FindString(command) + ready := regexp.MustCompile(`SSHX_SUDO_READY_[a-f0-9]+`).FindString(command) + _, suffix, ok := strings.Cut(command, `' sh `) + if prompt == "" || ready == "" || !ok { + return ch, command + } + _, _ = io.WriteString(ch.Stderr(), prompt) //nolint:errcheck // fixture transport teardown + reader := bufio.NewReader(ch) + password, err := reader.ReadString('\n') + if err != nil { + return ch, "fixture-auth-failed" + } + _, _ = io.WriteString(ch.Stderr(), ready) //nolint:errcheck // fixture transport teardown + return sudoFixtureChannel{ch, io.MultiReader(strings.NewReader(password), reader)}, "sudo -S -p '' " + suffix +} diff --git a/tests/e2e/sudo_native_e2e_test.go b/tests/e2e/sudo_native_e2e_test.go new file mode 100644 index 0000000..a2e50b3 --- /dev/null +++ b/tests/e2e/sudo_native_e2e_test.go @@ -0,0 +1,77 @@ +package e2e + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +// Explicit opt-in: users/sudoers belong to a disposable CI runner, provisioned +// by ci.yml. This test never edits the caller's accounts or credential stores. +func TestNativeSudoInputBoundary(t *testing.T) { + if os.Getenv("SSHX_E2E_NATIVE_SUDO") != "1" { + t.Skip("requires disposable native sudo fixture") + } + require.Equal(t, "linux", runtime.GOOS, "native fixture is Linux-only") + for _, role := range []string{"password", "nopasswd", "cached", "root", "wrong-password"} { + t.Run(role, func(t *testing.T) { + user := "sshx-sudo-" + role + if role == "wrong-password" { + user = "sshx-sudo-password" + } + if role == "root" { + user = "root" + } + require.NoError(t, exec.Command("id", user).Run(), "provision the isolated sudo fixture first") // #nosec G204 -- fixed fixture account list + if role == "cached" { + warm := exec.Command("sudo", "-n", "-u", user, "sudo", "-S", "-p", "", "-v") // #nosec G204 -- explicit disposable sudo fixture + warm.Stdin = bytes.NewBufferString(operatorPassword + "\n") + output, err := warm.CombinedOutput() + require.NoError(t, err, string(output)) + } + server := startSSHServer(t, serverOptions{execHandler: func(ch ssh.Channel, command, _ string) { + cmd := exec.Command("sudo", "-n", "-u", user, "/bin/sh", "-c", command) // #nosec G204 -- explicit disposable sudo fixture. + cmd.Stdin = ch + cmd.Stdout = ch + cmd.Stderr = ch.Stderr() + code := uint32(0) + if err := cmd.Run(); err != nil { + code = 1 + var exit *exec.ExitError + if errors.As(err, &exit) { + code = uint32(exit.ExitCode()) + } + } // #nosec G115 -- process exit status + sendExitStatus(ch, code) + }}) + home := t.TempDir() + env := map[string]string{"SSHX_E2E_KEYRING_FILE": filepath.Join(home, "keyring.json"), "SSH_PASSWORD": operatorPassword} + secret := operatorPassword + if role == "wrong-password" { + secret = "wrong-fixture-password" + } + set := runSSHXWithTestKeyring(t, home, []string{"--password-set=fixture:" + secret, "--no-audit"}, env) + require.Equal(t, 0, set.exitCode, set.stderr) + source := filepath.Join(home, "script.sh") + require.NoError(t, os.WriteFile(source, []byte("#!/bin/sh\nprintf 'native-payload-ok\\n'\n"), 0600)) + result := runSSHXWithTestKeyring(t, home, []string{"run", "--address=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", "--accept-unknown-host", "--bypass-reason=isolated-native-fixture", "-pk=fixture", "--sudo", "--script-file=" + source, "--timeout=5s", "--json"}, env) + if role == "wrong-password" { + assert.NotEqual(t, 0, result.exitCode) + assert.NotContains(t, result.stdout, `"stdout":"native-payload-ok`) + } else { + require.Equal(t, 0, result.exitCode, "stdout=%s stderr=%s", result.stdout, result.stderr) + assert.Contains(t, result.stdout, "native-payload-ok") + } + assert.NotContains(t, result.stdout, secret) + assert.NotContains(t, result.stderr, secret) + }) + } +}