You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Dependency advisories — russh, rustix, async-executor and webpki-roots
russh 0.62.5 is now 0.65.1, which closes RUSTSEC-2026-0492 (HIGH, client::Handler callbacks fired for a channel that was never opened) and RUSTSEC-2026-0495 (the agent server never called Agent::confirm)
The same upgrade closes GHSA-fw5c-8223-6h42, where a zero maximum packet size stalled every channel write, and GHSA-3hm4-p7h4-rvwp, a stack overflow before authentication triggered by a nested OpenSSH certificate
Handler::check_server_key now receives &PublicKeyOrCertificate, and the certificate branch is unreachable because Preferred::DEFAULT leaves host_key_certificates empty, so the embedded key goes through the same TOFU check
rustix 1.1.4, flagged unsound by RUSTSEC-2026-0486, is now 1.1.6, and the yanked async-executor 1.14.0 is now 1.14.1
webpki-roots 1.0.12 drops eight more distrusted roots: emSign Root CA - C1 and emSign ECC Root CA - C3 in 1.0.11, then Buypass Class 2, Buypass Class 3, DigiCert Trusted Root G4, QuoVadis Root CA 1 G3, QuoVadis Root CA 3 G3 and Certigna Root CA in 1.0.12
The advisory-hits gate read advisory.patched_versions while cargo audit publishes versions.patched, so it reported no patched release for advisories that had one, and it now reads the published field
Keyring backend
ssh-cli secrets init --keyring reported success and persisted nothing
The cause was the crate, not the machine: keyring = "3" resolved 3.6.3, which has no default feature and fell back to an in-process mock store
The mock kept the key in the memory of the process that wrote it, so the next invocation answered encryption_active: false and the agent held a success it could not rely on
The backend is now keyring 4.2.0 with the v1 feature, which compiles a real store on all three systems: Keychain on macOS, Credential Manager on Windows and Secret Service over zbus on the remaining unix targets
The store is consulted through Entry::store_status() before every write and every read, so an unavailable store exits 69 instead of answering Ok
A second cause was measured: the leaf flag secrets init --keyring never reached the runtime state, because dispatch forwarded only the global --use-keyring
A third cause was measured: a key stored by an earlier secrets init --keyring was invisible without repeating the flag
The keyring entry is scoped by a digest of the resolved config directory, because the OS store is machine-wide while secrets.key and config.toml are per profile
Without that scope, an isolated run with --config-dir overwrote the key of the real profile, which this audit measured happening
A fourth cause was measured in an intermediate 0.5.6 build itself: --use-keyring secrets init answered key_source: xdg_file, because the subcommand received only the leaf flag and the global one never reached the write path
A fifth cause was measured against a live profile: secrets init --keyring on a profile that already held secrets.key answered key_source: keyring and minted a second key, leaving the file to answer first on every later read
The profile then held two different primary keys, secrets status reported xdg_file, and every secret already encrypted failed to decrypt with the key the command had just named as active
The guard now asks the profile instead of the destination, so an existing key in either store refuses the write without --force, and --force removes the other source so exactly one key survives the rotation
D2: secrets init --force no longer writes secrets.key.bak
The backup copy left key material on disk after every rotation and violated the project rule against backup files
The store is reached through a scoped OS thread, because the zbus blocking facade parks the calling thread on an async-io reactor while the process runs a multi-thread Tokio runtime
Residual limits — keyring backend
The keyring round-trip is proved on Linux with a session bus, and macOS and Windows only reach type-check in the cross-targets gate, so the Keychain and Credential Manager stores are not executed by this release's battery
A machine without a session bus exits 69 and the tests report SKIP, never PASS
The config directory digest is an identifier, not a secret, so it does not resist a local attacker who can already read the store's entry names
TLS roots and advisory gate — A1 and A4
A1: the TLS (Transport Layer Security) root store no longer trusts a distrusted authority: webpki-roots 1.0.9, rustls-webpki 0.103.15 and webpki-root-certs 1.0.9 still carried emSign ECC Root CA - G3, and the eMudhra intermediates under it have a public private key (Mozilla Bug 2083449, CVE-2026-111943, RUSTSEC-2026-0381/0382/0383)
The client ACME (Automatic Certificate Management Environment) and every outbound TLS connection build their RootCertStore from that list, so the defect reached both
The minimum is pinned in the manifest, webpki-roots = "1.0.10", and the lock resolves rustls-webpki 0.103.17 and webpki-root-certs 1.0.10
The manifest pin exists on purpose: a bare lock update could walk back to the compromised version silently, and now it cannot build
A4: a new gate compares the advisory database with the resolved tree on every run: scripts/check_advisory_hits.py runs cargo audit --json and fails on any entry in vulnerabilities.list or on a yanked package
The older advisory-freshness gate measured only the age of the clone, so a stale database could keep the battery green over a live CVE
Residual limits — TLS roots and advisory gate
The advisory-hits gate refuses to run when the advisory database is older than seven days, so a disconnected machine fails instead of passing on stale data
The pin covers the three crates in the measured advisory: a future advisory against a different crate still depends on the next database refresh
Text output, host names and config directory
Terminal escape sequences no longer reach the operator's terminal in text mode: A host name, an error message or a stored field could carry ESC, BEL or bidi characters, and text mode printed them raw
src/output/sanitize.rs escapes control, formatting and bidi characters as \u{1b}-style text in the one text funnel: success, error and warning messages, vps list, vps show, health-check, doctor and the batch outputs
sftp ls and sftp stat also escape \n and \t in the remote name, so a hostile server cannot forge extra lines in a listing
JSON output is unchanged, because it is for machines and serde_json already escapes ESC and BEL
A new host name has a strict charset: VpsName::try_new accepts ASCII letters, digits, dot, hyphen and underscore, starting with a letter or digit, from 1 to 255 bytes
It guards vps add, the destination of vps rename and vps edit --name, and vps import
Reading stays lenient, so an old config.toml with a hostile name still loads, doctor lists the name escaped in local.invalid_host_names and vps rename repairs it
Two host tables that normalize (NFC) to the same name make loading fail with exit 65 and config has two hosts named ..., so a host never disappears silently
A name that becomes a target (exec, health-check, --hosts, connect) is refused with exit 64 when it carries control, formatting or bidi characters
tls mtls import --name refuses the same characters with exit 64 and creates no directory, so an identity name cannot become a hostile directory name
The config directory is born private: Every directory level the CLI creates gets mode 0700, where it used to inherit 0755 from the umask next to a 0600config.toml
An existing directory is tightened to 0700 only when it is the default XDG directory, is not a symlink and belongs to the current user
Any other existing directory is never chmodded: doctor reports local.config_dir_exposed and local.config_dir_permissions, and a log warning is visible with -v
vps export -o creates missing directories 0700, and known_hosts.lock and lang are born 0600 with no window before a chmod
RUSTSEC-2026-0235 is resolved by removal: The advisory came from rkyv, which entered the lock only through the optional rust_decimal, so the unused money module and that dependency are gone
The removal is a BREAKING change for library users and the changelog carries its migration path
The published package no longer carries the nine scripts/*.sh, the stray root file json or pending *.snap.new files, and the Cargo.tomlexclude keeps them out
The rename path has no re-sealing step of its own, and the credential is never retyped: VpsRecord seals its secrets without associated data, so the blob is not bound to the host name
The ciphertext token does change, because the ordinary save seals every secret again with a fresh nonce, and what survives is the credential, readable under the new name
The export, edit and import detour is gone: vps export redacts secrets by design, so the round trip forced an operator to retype a credential into argv or a file, which is the exposure the stdin paths exist to avoid
That export also carried no use_agent or agent_socket, so an agent-authenticated host came back unauthenticated and failed at the next connection instead of at the import
Clearing a field cannot silently leave a host unusable: The eight clear flags are refused with exit 64 when the result would have no primary authentication method, or when --no-agent-socket would strip the only endpoint on a platform that has no default
A malformed record at rest is worse than a refused edit, because every later command inherits it
Contention is bounded, so a hostile or wedged lock holder cannot make the CLI hang: The config lock waits --lock-wait-ms (default 5000, max 300000) under full-jitter backoff and then exits 69 unavailable, which is retryable
Before this, a peer process holding config.toml.lock kept every writer blocked indefinitely, with no output, no exit and no diagnosis
Residual limits — text output, host names and config directory
The stdout and stderr of a remote exec stay raw by design, so a hostile server can still write escape sequences into them
JSON keeps every character of the escaped set raw, for example U+202E and ZWSP, because JSON is for machines
Windows has no chmod, so the config directory relies on the profile ACL
The global shaping flags reduce collections only, so they did not reach the doctor object: read local.config_dir_exposed with ssh-cli doctor --json | jaq .local.config_dir_exposed (a later 0.5.6 change added --select for single records, so this limit describes the first 0.5.6 fixes)
macOS and Windows only pass through type-check in the cross-targets gate, so mode 0700 was not executed there
A legacy record with a control character in a tag, host or user still exits 65, because only the host NAME is tolerant on load
Error bodies stay in English by design, so agents receive stable text
Security Fixes in 0.5.5
GAP-SSH-EXEC-ARGC-001: the target host was chosen by positional count instead of by content (CWE-441). exec, sudo-exec and su-exec accepted one or two positionals: two meant <VPS> <COMMAND>, one meant "command against the host connect last marked"
--step is an appending flag, not a positional, so exec <HOST> --step <CMD> collapsed into the one-positional branch
The host name was then run as a remote binary (remote exit 127) and every --step after it executed on the marked host: a host the operator never typed
Because the later steps succeeded, the process exited 0, so the operator read success while systemd units, packages and firewall rules landed somewhere else
This is a security defect and not an ergonomics one: in a CLI for remote access the critical dimension is not what runs, it is where
It required no attacker: ambiguous argv from a legitimate operator was enough, which is why it outranks a bug needing a hostile peer
Fixed by making the ambiguity unrepresentable, not by guessing intent: A lone positional is now a usage error (exit 64) on every remote surface, health-check included
Designate the target explicitly with <VPS> <COMMAND>, with a selector (--all, --hosts a,b, --tags prod), or with the marker on purpose via the new --use-active
Opting in with no marker present is exit 66
Failure now happens during parse, before any socket opens, so the blast radius of the mistake is a usage message instead of an audit across two hosts
GAP-SSH-EXEC-ENVELOPE-002, the single-host envelope named no host, so the deflection above was undetectable from stdout: The fleet path already emitted a per-host name while the single-host path, the one that accepted the silent fallback, emitted none, so the asymmetry ran opposite to the risk
Every envelope now carries target_resolved, target_source (argv | active_marker | selector) and active_fallback, on the error envelope too once the target resolved, where they are absent rather than empty
host_resolved and host_source ship as 0.5.5 read aliases
Bind new consumers to the canonical names
doctor reports local.active_vps, so the marker is now legible by machine: before 0.5.5 no command exposed it and the only way to read it was opening the XDG file
Upgrade to 0.5.5 for both
Neither needs a remote attacker, and the first one silently rewrote the wrong machine
Security Fixes in 0.5.4
A1, remotely triggerable abort while logging the pre-auth banner: The SSH banner is remote input that arrives before authentication, and it is retained for diagnostic logging
The AUTH_BANNER_MAX_CHARS (512) log cap already existed (G-SSH-14)
The defect was that the cut was applied by byte index, which panics whenever it lands inside a multi-byte character
The release profile sets panic = "abort", so there is no unwind: an unauthenticated peer could kill the process outright with a banner containing a single well-placed non-ASCII character, taking a whole multi-host fan-out down with it
Truncation now happens on character boundaries
Note the residual limit of the fix: russh materialises the complete banner and hands it to the callback, so the client still holds the full string in memory, what A1 bounds is the panic, not the allocation
This is the highest-severity class here because it needs no credentials
A2, world-readable window on ACME and mTLS (mutual TLS) private keys: Key material under XDG tls/acme/ and tls/mtls/ was written with std::fs::write, which creates the file readable by any local user and only then tightens the mode, leaving a window a co-tenant could read
Writes now go through the shared helper that creates at 0600, so the window does not exist
Fixing the mode after the fact only shortens exposure
It never removes it
A3, server-sent elevation bits landing on downloaded files: The SCP C header and SFTP (SSH File Transfer Protocol) attributes are produced by the server, so the mode they carry is untrusted
Masking with 0o7777 let a hostile peer put setuid, setgid or sticky on the local file
Inbound modes are now masked with SFTP_PERM_MASK_UNTRUSTED (0o0777), so only plain rwx triples cross the network inbound
The remote end has no authority to decide what is privileged on your machine
Before this, --bind 0.0.0.0 silently published the forwarded remote service to the whole local network
Under --reverse the same flag guards the server's bind address, because that is the exposed end in that direction
Upgrade to 0.5.4 for all four
A1 and A3 are reachable by a remote peer
A2 is reachable by a local co-tenant
Reporting a Vulnerability
Report security issues through GitHub Security Advisories in the public ssh-cli repository as the preferred private channel
Use email at daniloaguiarbr@proton.me only as fallback when GitHub private reporting is unavailable
Never open a public GitHub issue, pull request, or discussion for security-related reports
Include a minimal reproduction, affected versions, and expected versus actual behavior
Include environment details such as OS, architecture, and rustc version
Include a CVSS v4.0 severity estimate when possible (CVSS 3.1 accepted as fallback) to accelerate triage
Redact live credentials from every attachment and log excerpt
Response SLA
Triage of every advisory starts within 72 business hours of submission
Initial acknowledgment is sent within that same 72-business-hour window
You receive a case identifier and an assigned maintainer contact
Progress updates are shared at least every 7 days until resolution or public disclosure
Fix SLA by CVSS Severity
Critical severity (CVSS 9.0 to 10.0) receives a patch within 7 calendar days of validated triage
High severity (CVSS 7.0 to 8.9) receives a patch within 14 calendar days of validated triage
Medium severity (CVSS 4.0 to 6.9) receives a patch within 30 calendar days of validated triage
Low severity (CVSS 0.1 to 3.9) receives a patch within 90 calendar days of validated triage
Released fixes include a CHANGELOG entry and a GitHub Security Advisory when the affected line is still supported
Disclosure Policy
Coordinated disclosure is the default for validated vulnerabilities
Public disclosure is delayed until a fixed release is available or the Fix SLA window expires with documented reason
Researchers may publish after mutual agreement or after the coordinated window ends
Security Update Policy
Security fixes land on the current minor line first
Backports to older minor lines happen only when impact and effort justify the work
Users must upgrade with cargo install ssh-cli --locked --force after a security release
python3 scripts/generate_sbom.py writes a CycloneDX SBOM (software bill of materials) to target/sbom/ssh-cli.cdx.json when cargo-cyclonedx is installed
Without cargo-cyclonedx the same command writes only a cargo tree inventory, which is not an SBOM
Hall of Fame
Security researchers who request public credit are listed here after coordinated disclosure completes
The list starts empty for the current ownership line under danilo-aguiar-br
Threat Model (product: G-SEC-10)
Trust boundary: CLI argv, env, XDG files (config.toml, secrets.key, known_hosts), and the remote SSH server are untrusted inputs
Only the local process identity and OS keyring (when enabled) are treated as higher-trust material
Assets: SSH passwords / key passphrases / sudo-su secrets (memory + at-rest), host-key fingerprints (TOFU), primary AEAD (Authenticated Encryption with Associated Data) key (secrets.key / keyring), the ability to run remote shell / SCP / local port forwards, and, as GAP-SSH-EXEC-ARGC-001 proved, the identity of the host reached, since the same argv running on the wrong machine is a breach even when every credential held
Adversaries considered: (1) hostile CLI/agent argv and env
(2) malicious or MITM SSH servers (host-key change)
(3) co-tenant local users reading world-readable files or process listings
(4) compromised transitive crates (supply chain)
(5) ambiguous argv from a legitimate operator or agent: no adversary required
GAP-SSH-EXEC-ARGC-001 was of this class: a well-formed command line that the product resolved to a host nobody named
Treat "which machine does this reach" as an attacker-controlled question even when no attacker exists
Out of scope: multi-tenant SaaS isolation, browser XSS/CSRF, container escape, kernel Spectre mitigations beyond normal process isolation, and long-lived daemon attack surface (this CLI is one-shot)
Controls: typed validation at CLI/import/path boundaries
SecretString + zeroize
ChaCha20-Poly1305 at rest
TOFU host keys with constant-time fingerprint compare
Shell packing with single-quote escape and secrets on channel stdin
No local shell-out for SSH
Terminal-safe text output: control, formatting and bidi characters are escaped before any text reaches the terminal (0.5.6)
Strict host-name and mTLS identity-name charset on creation (0.5.6)
Config directory born 0700 (0.5.6)
deny.toml + local cargo deny check
Release overflow-checks
Documented unsafe only at OS console/signal edges
Review trigger: re-read this model on any change that adds network protocols, long-lived processes, new secret stores, or unsafe outside OS FFI wrappers
Transport & crypto policy (G-TLS)
Default path: product network transport is SSH-2 via russh on plain TCP
Wire crypto uses the aws-lc-rs backend (ssh-real)
Host authentication is TOFU (known_hosts under XDG)
Optional SSH-over-TLS: when a VPS has tls = true (or --tls on vps add/edit), the client dials TCP, completes a rustls handshake (SNI + optional mTLS), then runs SSH on the TLS stream
Floor: rustls ≥ 0.23.18 (never 0.23.13–0.23.17 Acceptor CVE window), the min_rustls value that ssh-cli tls provider prints
The manifest asks for more than the floor: Cargo.toml declares rustls = { version = "0.23.25" }
Provider: binary main calls CryptoProvider::install_default with aws_lc_rs only (once, before the Tokio runtime)
Libraries use ClientConfig::builder / process default: they never reinstall
Dual provider ring is banned in deny.toml
Forbidden stacks: no native-tls, OpenSSL, libssh2-sys, or product use of ring
Password on argv is still parseable, with a warning, so prefer stdin
The remote host after authentication is fully trusted for the one-shot command the operator requested, a premise that only holds once the target is designated explicitly, which is why 0.5.5 made designation mandatory
A co-tenant with the same UID can read process memory, which is OS isolation and not this CLI
Spectre and CPU side-channel threats are out of scope for a one-shot userland tool
Logging and secret leakage (G2 / G14)
Never: rely on ambient RUST_LOG for product debugging, the product ignores it (not a config store)
Use graduated flags only: -v (info) / -vv (debug) / -vvv (trace) via ArgAction::Count
The filter is always crate-scoped (warn,ssh_cli=*): never bare global debug/trace
This keeps russh (and other deps) from dumping passwords or channel material onto stderr
Even -vvv does not enable russh encrypted-channel dumps
Only the ssh_cli crate (plus warn-level deps) is allowlisted
Prefer 0.5.3+ so agents cannot accidentally enable global debug that leaked credentials in older lines
Best Practices for Users
Prefer private key authentication over password authentication when the host allows it
Prefer --password-stdin, --sudo-password-stdin, and --su-password-stdin over argv secrets (password-on-argv emits a stderr warning on 0.5.2+)
Prefer stdin password flags for agent runs
Avoid embedding live secrets in shell history
Default at-rest encryption (ChaCha20-Poly1305): on first secret write, auto-creates secrets.key (0o600) next to config.toml unless you opt out
Secrets control is CLI/XDG only: --allow-plaintext-secrets, --secrets-key-file, --use-keyring, or XDG secrets.key
No .env product store
Key resolution order: --secrets-key-file → keyring (when --use-keyring) → XDG secrets.key → keyring holding a key from an earlier secrets init --keyring
SSH_CLI_SECRETS_KEY / SSH_CLI_SECRETS_KEY_FILE are rejected fail-closed (not a store)
CLI: ssh-cli secrets status|init|reencrypt (never prints the master key)
--json emits secrets-init / secrets-reencrypt without key material
Opt-out for tests only: --allow-plaintext-secrets (no env store)
Never: log the master key, passwords, or decrypted secrets
--include-secrets to pipe/non-TTY requires -o/--output or --i-understand-secrets-on-stdout (guard against accidental secret dump on stdout)
Keep config.toml mode 0600 and restrict backup locations
Review TOFU host-key change errors before using --replace-host-key
Never commit host registries that include live secrets
Never commit local tooling sidecars (e.g. .setting.cyber/), the Grok config (~/.grok/config.toml), XDG config.toml / secrets.key / known_hosts, or the password files handed to the E2E script (--password-file, --sudo-password-file) into the repository
Real-SSH E2E (G-E2E-05) must keep credentials outside the tree: prefer python3 scripts/e2e_real_ssh.py --local-sshd (unprivileged sshd on loopback, Unix only), or the lab flags --host, --port, --user and --password-file (plus --sudo-password-file, a path or - for stdin), or $HOME/.grok/config.toml via --from-grok-config
The script takes flags only and reads no environment variables
Offline / no lab → SKIP exit 0. The script refuses grok configs under the repo root
Demo passwords in public docs are placeholders only (e.g. demo-password-not-real)
Never reuse them on live hosts
Disable elevation with --disable-sudo when a workflow must not escalate
Keep tunnel --bind on loopback unless a second machine genuinely must reach the forward
Any routable bind requires --i-accept-network-exposure, which is the acknowledgement of that exposure rather than a formality
Treat tunnel --remote-socket /var/run/docker.sock as privilege delegation: forwarding a Docker or systemd socket hands the local port whatever authority that socket grants on the remote host
Prefer tunnel --socks5 bound to loopback over a routable fixed forward when an agent needs several destinations
One acknowledged local bind beats N exposed ones
Run one-shot commands only
Never expect a long-lived SSH daemon from this CLI
Install with --locked to avoid accidental crypto re-resolve drift
Read machine output as JSON, because text mode escapes control characters for the human reader
Keep the config directory private: The CLI never chmods an existing directory that is not the default XDG one, so read local.config_dir_exposed in ssh-cli doctor --json and run chmod 700 yourself when it is true
After upgrading to 0.5.6, read local.invalid_host_names in ssh-cli doctor --json and repair each legacy name with ssh-cli vps rename
Switch a host between password, key and agent with vps edit, which clears the old secret, instead of the export, edit and import detour
Prefer current 0.5.3+ for SFTP integrity (G1 closed 0-byte upload truncation), crate-scoped verbosity (G2/G14: no russh password dumps), the supply-chain floor (russh 0.65.1), working SCP/SFTP wire, ACME permanent classification (invalidContact → exit 64), and the three-asterisk mask (FIXED_MASK) shown by vps list and vps show
Historical honesty: 0.4.1 fixed empty-secret redacted export (never sshcli-enc: of empty) and tunnel post-bind exit 0
Default redacted vps export clears secrets: password is an empty string and the other secrets are null
Empty secrets must serialize as empty strings, never encrypted sshcli-enc: blobs of empty values (0.4.2 EXP-001)