Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -276,18 +276,30 @@ jobs:
done <<< "$TAGS"
docker buildx imagetools create "${TAG_ARGS[@]}" "${IMAGE}@${DIGEST}"

# Intended as proof, in the job log, that every published tag resolves to
# the bytes Trivy cleared. As written the check cannot pass: `imagetools
# create` above wraps the single-platform manifest in a NEW OCI index, so
# the tag's digest legitimately differs from the scanned one (and hashing
# `inspect --raw` stdout folds in a trailing newline the registry's
# manifest bytes do not have). Since 2026-07-26 it fails on every publish
# run — after the tags are already live — which also stops the attest step
# below from running. A failure here means the comparison is broken, NOT
# that the scan gate was bypassed. FIXME: compare the manifest digest the
# registry reports (`imagetools inspect --format '{{.Manifest.Digest}}'`),
# accounting for the index wrapping.
- name: Verify every tag resolves to the scanned digest
# Proof, in the job log, that every published tag serves the bytes Trivy
# cleared — nothing more.
#
# Production incident 2026-07-26: the original check compared
# `sha256sum` of `imagetools inspect --raw` against the build digest and
# failed on EVERY publish run, taking the attest step below down with it
# (images published between 2026-07-26 and this fix carry no provenance
# attestation). Confirmed cause: `imagetools create` does NOT reuse the
# source digest — it wraps the single-platform manifest in a NEW OCI
# index, so the tag's own digest legitimately differs from the scanned
# manifest's. Verified against the live registry: `:latest` was index
# sha256:8204263e… whose sole child was the scanned sha256:12db37ce…,
# and re-hashing those bytes reproduces 8204263e exactly.
# (Piping `inspect --raw` into `sha256sum` is separately fragile if the
# CLI appends a newline — not the cause here, but a reason not to go back
# to hashing stdout.)
#
# So compare the right thing: the set of image manifests the tag actually
# serves must be exactly {scanned digest}. Attestation manifests that
# buildx/attest may attach are excluded — they are metadata about the
# image, not another runnable image. Do not "simplify" this back to
# comparing the tag's own digest; that asserts an invariant the promote
# step deliberately does not hold.
- name: Verify every tag serves only the scanned digest
if: env.PUBLISH == 'true'
env:
TAGS: ${{ steps.meta.outputs.tags }}
Expand All @@ -296,17 +308,33 @@ jobs:
set -euo pipefail
while IFS= read -r tag; do
[ -z "$tag" ] && continue
resolved="$(docker buildx imagetools inspect --raw "$tag" | sha256sum | cut -d' ' -f1)"
echo "${tag} -> sha256:${resolved} (scanned ${DIGEST})"
if [ "sha256:${resolved}" != "${DIGEST}" ]; then
echo "::error::${tag} does not resolve to the scanned digest ${DIGEST}" >&2
raw="$(docker buildx imagetools inspect --raw "$tag")"
# An index lists children under .manifests; a bare manifest has none.
served="$(printf '%s' "$raw" | jq -r '
if .manifests then
[ .manifests[]
| select((.annotations["vnd.docker.reference.type"] // "")
!= "attestation-manifest")
| .digest ] | join(" ")
else
empty
end')"
if [ -z "$served" ]; then
served="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$tag")"
fi
echo "${tag} serves: ${served} (scanned ${DIGEST})"
for d in $served; do
if [ "$d" != "${DIGEST}" ]; then
echo "::error::${tag} serves ${d}, which is not the scanned digest ${DIGEST}" >&2
exit 1
fi
done
if [ -z "$served" ]; then
echo "::error::${tag} resolved to no image manifest at all" >&2
exit 1
fi
done <<< "$TAGS"

# Unreachable on the publish path today: the verify step above fails
# first, so images pushed to GHCR since 2026-07-26 carry no provenance
# attestation. Fixing verify restores this.
- name: Attest build provenance for the image
if: env.PUBLISH == 'true'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
Expand Down
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

> **Release note for whoever cuts this:** ship as **0.6.0**, not 0.5.7. Two independent reasons: the `mcp` floor in `[project.dependencies]` is narrowed from `>=1.27.0` to `>=1.28.1`, and the SFTP path contract is a **breaking change** (see everything under *Changed — BREAKING* below).
## [0.6.0] - 2026-07-26

> **Why 0.6.0 and not 0.5.7:** two independent reasons — the `mcp` floor in `[project.dependencies]` is narrowed from `>=1.27.0` to `>=1.28.1`, and the SFTP path contract is a **breaking change** (see everything under *Changed — BREAKING* below).

### Security — please read if you run ssh-mcp

Expand All @@ -25,6 +27,10 @@ Two further hardening fixes in the same area: the audit record now reports the f

**Denial of service in credential redaction.** `_redact_secrets` ran in quadratic time, so a ~10 KB command stalled the single-threaded server for over six seconds, reachable without an SSH connection or SSH authentication via `dry_run=true`. There was no length limit on a command anywhere. The two rules responsible — URL basic-auth and long-flag matching — are now hand-written token-wise scanners that visit each token once (~0.007 s for 100 KB); the remaining rules stay on Python's `re` engine, which their lookbehinds need. `max_command_bytes` (default 64 KiB) caps command length at the tool boundary. Multi-word *quoted* credential values remain only partially redacted — unchanged from previous behaviour; redaction is a tripwire, and credentials should be passed via env files or stdin rather than argv.

**Credential redaction missed values beginning with a dash.** In the space-separated form (`--password <value>`, not `--password=<value>`), a value starting with `-` was left unredacted, so it reached logs and OTel spans in plaintext. The scanner treated any following token that began with `-` as "this flag was boolean, leave the next token alone" — a guard that exists to fix an earlier leak where a credential flag following a boolean flag was silently skipped — and a password beginning with `-` is indistinguishable from a flag under that rule. Generated passwords routinely start with `-`, so this was not a corner case. The guard is now narrowed to `--`: a single-dash token is redacted, while a `--`-prefixed token is still left for its own classification turn, keeping the earlier fix intact. **Residual, unchanged and deliberate:** a value beginning with `--` is still treated as a flag and not redacted. Redaction remains a tripwire — pass credentials via env files or stdin rather than argv.

**`SSH_MCP_HTTP_ALLOWED_HOSTS=*.*` silently disabled DNS-rebinding protection.** The startup gate refuses bare wildcards, and `*.*` was listed in its refusal set, but the allowance for legitimate suffix wildcards (`*.internal.example.com`) was evaluated first, so `*.*` — which matches effectively any dotted hostname — was accepted. On the HTTP transport that is remote command execution behind a rebinding attack. Validation now strips only the two deliberately-permitted wildcard forms (a leading `*.` subdomain wildcard and a trailing `:*` port wildcard) and refuses anything with a wildcard left over; the same change also starts refusing infix wildcards such as `a*b.example.com`, which the previous check never caught, and a whitespace-only value, which previously fell back silently to loopback-only. Suffix wildcards remain permitted as documented.

**Audit records could be forged.** The success and timeout audit paths interpolated the command without escaping control characters, so an embedded newline produced a convincing fake log line. All audit paths now escape.

Also cleared every advisory reported by `pip-audit`. **None of the four is exploitable in ssh-mcp** — this is defence-in-depth and a CI unblock, not an incident response. Being precise, because the two are not the same thing: three of the four are *unreachable* (the vulnerable code is never imported or never called), while CVE-2026-52869's session manager **is** in the HTTP request path — it is reachable but its exploit precondition cannot be satisfied by ssh-mcp's authentication model. Details per advisory below. Reachability was verified by grep over `src/` and the non-exploitability argument for CVE-2026-52869 was independently stress-tested by two external models.
Expand Down Expand Up @@ -67,7 +73,8 @@ Also cleared every advisory reported by `pip-audit`. **None of the four is explo

**SFTP no longer raises a bare `KeyError`** for an unknown server, which fell outside its documented `ValueError`/`RuntimeError` contract. A non-ASCII bearer token now returns 401 rather than 500 (it always failed closed). `$XDG_CONFIG_HOME` is honoured for the config search path, matching the new `$XDG_DATA_HOME` handling.

- **The `docker` job scanned an image it did not publish.** It ran BuildKit twice — a `load: true` image for Trivy and a separately built pushed image — which by construction cannot share a digest, so the scanned bytes were never provably the shipped bytes. There is now one build, pushed to GHCR *by digest with no tag attached* (`push-by-digest=true,name-canonical=true`); Trivy scans `${IMAGE}@${digest}`; tags are created from that same digest only after the scan passes, and a verification step fails the job if any tag resolves elsewhere. Build provenance is recorded as a GitHub Artifact Attestation against the digest.
- **The `docker` job scanned an image it did not publish.** It ran BuildKit twice — a `load: true` image for Trivy and a separately built pushed image — which by construction cannot share a digest, so the scanned bytes were never provably the shipped bytes. There is now one build, pushed to GHCR *by digest with no tag attached* (`push-by-digest=true,name-canonical=true`); Trivy scans `${IMAGE}@${digest}`; tags are created from that same digest only after the scan passes, and a verification step fails the job if any tag serves anything other than the scanned digest. Build provenance is recorded as a GitHub Artifact Attestation against the digest.
- **That tag-verification step was itself broken, and it suppressed provenance attestation.** `docker buildx imagetools create` does not reuse the source digest — it wraps the scanned manifest in a *new* OCI index — so comparing the tag's own digest against the build digest compared an index against one of its children and failed on every push to `main`. Because the check runs before the attestation step, images published to GHCR between 2026-07-26 and this release carry **no provenance attestation**. The scan gate itself was never bypassed: Trivy runs against the digest before any tag is promoted, so published content was always the scanned content. Verification now asserts the invariant that matters — the set of image manifests a tag serves is exactly the scanned digest — by inspecting the index's children and ignoring attestation manifests.
- **`SECURITY.md` described dangerous-command detection as "intentionally non-blocking".** The shipped code blocks: a match returns an error instead of executing, and `force=true` is the documented bypass. The policy now says so, and lists the acknowledged obfuscation bypasses rather than implying the check is advisory.
- **CI `Lint` job was non-deterministic and had begun failing on unmodified code.** It invoked `uvx ruff` and `uvx bandit`; `uvx` resolves the newest release at run time. ruff **0.16.0** (2026-07-23) expanded its default rule set from **59 to 413 rules**, so the same command that printed `All checks passed!` on 2026-07-16 reported 50 errors days later with zero code changes. Lint tooling is now pinned (`ruff==0.16.0`, `bandit==1.9.4`) in the `dev` extra and invoked via `uv run`, so the gate is a function of the commit rather than of the outside world.
- **The repo now declares its own lint standard.** There was no `[tool.ruff]` config at all, so the standard was whatever default the installed ruff happened to ship. `[tool.ruff.lint] select = ["E4", "E7", "E9", "F"]` codifies the pre-0.16 default the codebase has always been held to and is 100% clean against — no rule is dropped. Adopting ruff 0.16's wider default is tracked as a separate deliberate change.
Expand Down
2 changes: 1 addition & 1 deletion src/ssh_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""SSH MCP Server - Manage infrastructure via MCP clients like Claude Code."""

__version__ = "0.5.6"
__version__ = "0.6.0"
48 changes: 43 additions & 5 deletions src/ssh_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,14 @@ def _run_http() -> None:
) from e
token = raw_token or None
stateless = os.environ.get("SSH_MCP_HTTP_STATELESS", "false").lower() == "true"
allowed_hosts_env = os.environ.get("SSH_MCP_HTTP_ALLOWED_HOSTS", "").strip()
# Keep the raw (pre-strip) value around so we can tell "unset" apart
# from "explicitly set to whitespace" below — os.environ.get(..., "")
# collapses both to "", which would let a blank templated env var
# (e.g. "${EXTRA_HOSTS:- }") silently fall back to the localhost-only
# default instead of failing loud on what is almost always a config
# generation mistake.
_allowed_hosts_raw = os.environ.get("SSH_MCP_HTTP_ALLOWED_HOSTS")
allowed_hosts_env = (_allowed_hosts_raw or "").strip()

# Auth-mode dispatch. Default ``bearer`` preserves v0.3.1 behavior.
# ``none`` disables the bearer middleware entirely — useful when a
Expand Down Expand Up @@ -963,17 +970,48 @@ def _run_http() -> None:

base_hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
extra_hosts: list[str] = []
if (
_allowed_hosts_raw is not None
and _allowed_hosts_raw != ""
and not allowed_hosts_env
):
# Explicitly set but whitespace-only after stripping. Distinct from
# "unset" (which falls through to the localhost-only default below)
# — a whitespace value almost always means a broken env-file
# substitution, and silently treating it as "no extra hosts" would
# mask that from the operator.
raise RuntimeError(
"SSH_MCP_HTTP_ALLOWED_HOSTS is set but contains only whitespace. "
"Unset the variable to use the localhost-only default, or "
"provide a concrete hostname (e.g. 'ssh-mcp.internal:*')."
)
if allowed_hosts_env:
extra_hosts = [h.strip() for h in allowed_hosts_env.split(",") if h.strip()]
# H4: reject wildcards — they silently disable DNS-rebinding
# protection. An operator setting "*" almost certainly means
# "match my specific hostname" and doesn't realize the security
# implication. Fail loud instead of silently letting it through.
#
# Ordering trap (regression found on ci/fix-digest-verification):
# the previous implementation special-cased entries starting with
# "*." as "always a permitted suffix wildcard, skip refusal" via a
# `continue` evaluated before the `entry in {"*", "*:*", "*.*"}`
# refusal was reached. "*.*" itself starts with "*." too, so that
# `continue` fired first and let "*.*" — which matches essentially
# any dotted hostname and is semantically identical to the bare
# "*" this gate exists to block — through as PERMITTED even though
# it was listed in the refusal set. Do not reintroduce a
# startswith("*.")-first shortcut. The fix below strips the two
# deliberately-permitted wildcard *forms* (a trailing ":*" port
# wildcard, then a leading "*." subdomain wildcard) and only
# afterwards demands a concrete, wildcard-free remainder.
for entry in extra_hosts:
bare = entry.replace(":*", "").replace("*", "")
if not bare or entry in {"*", "*:*", "*.*"} or entry.startswith("*."):
if entry.startswith("*."):
continue # wildcard suffixes like *.internal.example.com are OK
remainder = entry
if remainder.endswith(":*"):
remainder = remainder[:-2]
if remainder.startswith("*."):
remainder = remainder[2:]
if not remainder or "*" in remainder or not remainder.strip("."):
raise RuntimeError(
f"SSH_MCP_HTTP_ALLOWED_HOSTS wildcard entry {entry!r} "
"would disable DNS-rebinding protection. "
Expand Down
Loading
Loading