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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- Host key verification no longer prints a false `REMOTE HOST IDENTIFICATION HAS CHANGED!` warning when `~/.ssh/known_hosts` matches the target through a wildcard pattern (`192.168.1.*`, `*.example.com`, …). The host key algorithms offered during negotiation are taken from `known_hosts`, but that lookup compared host fields for exact equality only, so a pattern entry was treated as "host unknown" and no restriction was applied. Against a server offering several host key types — which Windows OpenSSH does (rsa, ecdsa, ed25519) — the client could then negotiate a type absent from `known_hosts`, and the verification callback, which *does* expand patterns, reported it as a changed host key. Host matching for this lookup now follows OpenSSH: `*` and `?` wildcards, comma-separated lists, and `!` negation. `@cert-authority` and `@revoked` lines are skipped, because the algorithm on a `@cert-authority` line belongs to the CA key rather than the host key. Removal of stale entries deliberately keeps matching on exact equality, so accepting a changed host key never deletes a pattern line covering other hosts.
- The "remote host is not Windows" check no longer aborts on unrelated output. Unix shell prefixes (`bash:`, `sh:`, …) were matched as substrings anywhere in the remote output, so text such as `ssh: …` or `Publish: …` — both of which contain `sh:` — made the tool exit with "remote host does not appear to be Windows". Those prefixes are now anchored to the start of a line, matching the convention already used for the result markers.

### Changed

- Updated dependencies: `golang.org/x/crypto` v0.55.0 → v0.56.0. Versions up to v0.55.0 are affected by GO-2026-6354 and GO-2026-6355, two denial-of-service issues in `golang.org/x/crypto/ssh` channel handling that `govulncheck` reports as reachable from this tool's `ssh.Dial` call. After the update `govulncheck ./...` reports no reachable vulnerabilities.
- Updated dependencies: `golang.org/x/crypto` v0.56.0 → v0.57.0, `golang.org/x/term` v0.45.0 → v0.46.0. Routine bump to the current releases; no advisory applies to v0.56.0. `govulncheck ./...` reports no reachable vulnerabilities under both Go 1.27.1 and the pinned `toolchain go1.26.6`.

## [1.8.2] - 2026-08-15

Expand Down
55 changes: 55 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
# LESSONS

## known_hosts のワイルドカード照合と、読み取り経路 / 削除経路の非対称性 (2026-09-18)

### ホストパターンの展開は「読み取り専用の照合」にだけ入れる
- `hostKeyAlgorithmsFromKnownHosts`(ネゴシエーション制限用の読み取り)と
`replaceHostKeyInKnownHosts`(鍵変更を承認したときの行削除)が同じ照合関数を共有していた。
照合が完全一致だったためパターン行が「未登録」扱いになり、制限なしでネゴシエートした結果、
known_hosts に無い種別の鍵が選ばれて正常なホストに MITM 警告が出ていた
- **却下した案**: 共有している照合関数自体をワイルドカード対応にする — 1 行で直るが、削除経路が
同じ関数を使っているため `192.168.1.*` のような 1 行が「マッチした」と判定されて丸ごと消え、
同じパターンに覆われていた他ホストの鍵まで巻き添えになる。読み取りの誤警告を直すために
破壊的な削除を招き入れる交換になる
- **決め手**: 照合を用途で 2 つに割った。読み取りは OpenSSH 準拠(`*` / `?` / カンマ列 / `!` 否定)、
削除は完全一致のまま据え置き。修正前のコードで回帰テストが
`REMOTE HOST IDENTIFICATION HAS CHANGED` を出して落ち、修正後に通ることを確認した
(サーバに rsa と ed25519 を両方持たせ、known_hosts にはワイルドカードで ed25519 だけを登録する
構成で再現。単一鍵のテストサーバでは種別のズレが起きないため再現しない)
- `@cert-authority` 行が持つのは CA 鍵の種別でホスト鍵の種別ではない。パターン対応を入れると
この行が拾われて制限リストに混ざり、証明書ベースのホストへの接続を壊す。マーカー行は明示的に
スキップし、該当ホストが他に無ければ nil(=制限なし)へ落とす
- **副作用の確認**: 照合を広げると、これまで「未登録=制限なし」で通っていた構成に制限が付く。
パターン行が実サーバと別種別の鍵を指していると `no common algorithm for host key` で
ハンドシェイクが落ちるため、回復は `shouldRetryWithoutHostKeyAlgorithms` の再試行頼みになる。
この判定は sentinel error が無く文字列一致なので、実ハンドシェイク由来のエラーで再試行が
発火することをテストで固定した(判定を潰すと実際に落ちることも確認)。予測文字列だけの
ユニットテストでは、依存を上げたときに文言が変わっても気付けない
- **覆す条件**: 削除経路をパターン対応にする必要が出たら、行を消すのではなく「マッチしたホスト名
だけをホストフィールドから除く」形にできるか先に検討する(パターン行では表現できないので、
OpenSSH 自身も `ssh-keygen -R` でパターン行を消さない)

### 部分一致でシェル名を探すと無関係な出力に当たる
- 非 Windows ホスト判定が `strings.Contains(output, "sh:")` を使っており、`ssh: …` や `Publish: …`
のような文字列でも真になった。判定が真のときは graceful degradation ではなく即中断するため、
誤検知のコストが大きい
- **決め手**: シェル名は**行頭一致**に限定し、`command not found` のような十分に特徴的な
メッセージ断片だけを部分一致で残した。マーカー解析(`markerValue` / `hasMarkerLine`)で
既に同じ結論に達していたので、そのルールに揃えただけとも言える
- 行頭一致へ移す際に `env:` を候補に入れかけたが外した。PowerShell の `$env:VAR` 名前空間と
衝突し、このツール自身が実行する `Write-Output $env:SSH_CONNECTION` の出力を非 Windows と
誤判定し得る。緩い matcher を 1 つ外して別の緩い matcher を足しては意味がない
- **覆す条件**: なし(部分一致に戻す理由が見当たらない)

## Scoop の manifest は bucket リポジトリ直下ではなく `bucket/` に置く (2026-08-16)

### 直下レイアウトは scoop から「見えていない」
Expand Down Expand Up @@ -479,3 +520,17 @@
- 却下した案: (A) remote 構成をそのままにし、GitLab を指す `origin` に対して依存更新のブランチを push する。(B) GitHub remote を追加するだけに留め、master の fast-forward と GitLab への push は保留する。
- 決め手: `git remote -v` の出力が `origin git@gitlab.com:kwrkb/ssh-pushkey.git` の 1 件のみで、GitHub remote が存在しなかった。`gh api` で取得した GitHub の master は `6a567fb`、ローカルと `gitlab/master` は `8b91f5f`(乖離ではなく fast-forward 可能)。先行分 3 件には `574daf5 fix: write the Scoop manifest into bucket/` が含まれ、これは Scoop manifest の出力先修正。PLAN.md は GitHub Releases を配布の正本、Scoop manifest はそこから GoReleaser が生成すると定めているため、(A) のまま `v*` タグを push すると GitLab の release ジョブだけが走り、正本のリリースと bucket 更新が沈黙して欠落する。(B) では GitLab が 3 コミット遅れたままになり、次回 master push で非 fast-forward reject を招く。よって `origin` を `gitlab` にリネーム → `origin`=GitHub を追加 → master を `6a567fb` へ fast-forward → GitLab にも push、で 3 者を揃えた。CI・GoReleaser・Makefile は `origin` を参照していないことを grep で確認済み(参照は `.claude/CLAUDE.md` の記述のみ)。
- 覆す条件: 配布の正本が GitHub Releases でなくなる、または Scoop bucket の manifest 生成が GitHub リリースに依存しなくなった場合。その時は remote の主従を再定義してよい。

---

## 2026-09-18: known_hosts のパターン照合を大文字小文字非依存にする際、畳む場所を 1 関数に閉じた

- 却下した案: (A) パターン照合を呼ぶ側(カンマ区切りを分解する関数)で `addr` をまとめて lowercase してから各パターンに渡す。(B) 接続先ホスト名を CLI/config 解決の時点で lowercase し、以降すべて正規化済みとして扱う(OpenSSH クライアント本体と同じ方針)。(C) 照合を広げるついでに、鍵変更時の**削除**経路の完全一致比較も同様に畳んで左右対称にする。
- 決め手: (A) は同じ `addr` がハッシュ化エントリの HMAC 照合にも渡っており、HMAC は入力バイトに厳密なので、呼び出し側で正規化すると混合ケースのホストでハッシュ行が一切引けなくなる(実際、畳まないことを固定する回帰テストを追加した)。(B) と (C) を退けた根拠は同じで、下回りのホスト鍵検証ライブラリのソースに大文字小文字を畳む処理が 1 箇所も無く、アドレス正規化関数も lowercase しないことを確認したこと。削除経路が走るのはそのライブラリのコールバックが「鍵が変わった」と判定した後だけなので、こちら側だけ照合を広げると、ライブラリが一致させていない行まで削除対象に入る。読み取り専用の照合を上流仕様(パターンもホスト名も lowercase してから比較)に合わせるのは安全だが、破壊的操作の範囲は下回りが一致させた範囲を超えてはいけない。
- 覆す条件: 下回りのライブラリ自身が大文字小文字を畳むようになった場合、または接続先ホスト名を入口で正規化する方針に切り替えた場合。その時は削除経路とハッシュ照合の前提を洗い直す。

## 2026-09-18: シェルの自己申告は「行頭一致」だけでは足りず、絶対パスの basename も見る必要があった

- 却下した案: (A) 以前に削除した「コマンド名 + not found」という固定文字列を部分一致の判定断片として復活させる。(B) シェル名の部分一致を出力全体に戻す。
- 決め手: 一部の軽量シェルは絶対パスで名乗り(`<絶対パス>: <行番号>: <コマンド>: not found`)、この形は「コマンドが無い」ことを示す既存のメッセージ断片のどれにも一致せず、行頭もシェル名では始まらないため素通りしていた。素通りすると「対象 OS が違う」と報告せずに配置処理へ進む。(B) は直前のコミットで直したばかりの誤検知(無関係な語が `sh:` を部分文字列として含むだけで発火する)をそのまま戻すので不可。(A) は 1 つのシェルにしか効かず、他の絶対パス形を次に踏む。行頭が `/` のときにかぎり最初のコロンまでを basename に落として既存リストと突き合わせる形にすると、誤検知耐性(`/` 始まりでない行は従来どおり行頭一致のみ)を保ったまま全シェルに効いた。
- 覆す条件: 判定対象の出力に `/` で始まりコロンを含む正常行が現れるようになった場合。その時は basename 照合の発火条件を狭める。
68 changes: 57 additions & 11 deletions deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"net"
"path"
"strconv"
"strings"

Expand Down Expand Up @@ -186,24 +187,69 @@ func effectiveAdminKeysFromSshdT(output string) (isAdmin bool, ok bool) {
return false, false
}

// nonWindowsMessageSignatures は「非 Windows ホスト」を示すメッセージ断片。
// いずれも十分に特徴的なので出力のどこに出ても判定に使える。
var nonWindowsMessageSignatures = []string{
"command not found",
"not recognized as",
"unknown command",
"no such file or directory",
"not supported on this platform",
"platformnotsupported",
}

// nonWindowsShellPrefixes は Unix シェルがコマンド不在を報告するときの行頭。
// これらは**行頭一致**、または行頭が `/` の場合にかぎり**絶対パスの basename 一致**
// でしか見ない。無条件の部分一致にすると "ssh:" や "…finish:" のような
// 無関係な文字列が "sh:" を含むだけで非 Windows と誤判定され、graceful degradation では
// なく「Windows ではない」と即座に中断してしまう(markerValue / hasMarkerLine と同じ方針)。
// `env:` は入れない。PowerShell 自身の `$env:VAR` 名前空間と衝突し、
// `Write-Output $env:SSH_CONNECTION` を含むこのツールの出力を非 Windows と誤判定し得る。
// `env: 'powershell': No such file or directory` は message 側の断片で拾える。
var nonWindowsShellPrefixes = []string{
"bash:", "sh:", "zsh:", "ksh:", "csh:", "tcsh:", "dash:", "ash:", "fish:",
"powershell:",
}
Comment on lines +209 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize absolute-path shell prefixes

When the remote Unix login shell identifies itself by an absolute path, this prefix list no longer recognizes the failure. For example, /bin/sh -c powershell emits /bin/sh: 1: powershell: not found; it contains none of the message signatures and does not start with sh:, so looksLikeNonWindows returns false. resolveKeyFileTarget then treats this as a recoverable admin-check failure and proceeds down the Windows deployment path instead of reporting that the target is unsupported. Preserve the false-positive fix while accepting basename prefixes such as /bin/sh: (or specifically retaining the removed powershell: not found signature).

Useful? React with 👍 / 👎.


// hasNonWindowsShellPrefix は小文字化済みの 1 行が Unix シェルの自己申告で始まるか判定する。
// シェルは自分の名前(`sh:`)でも絶対パス(`/bin/sh: 1: powershell: not found`)でも
// 名乗るため、行頭が `/` のときにかぎり最初の `:` までを basename に落として照合する。
// `/` 始まりに限定しているのは "ssh: handshake failed" のような行を巻き込まないため。
func hasNonWindowsShellPrefix(line string) bool {
for _, prefix := range nonWindowsShellPrefixes {
if strings.HasPrefix(line, prefix) {
return true
}
}
if !strings.HasPrefix(line, "/") {
return false
}
colon := strings.Index(line, ":")
if colon <= 0 {
return false
}
base := path.Base(line[:colon]) + ":"
for _, prefix := range nonWindowsShellPrefixes {
if base == prefix {
return true
}
}
return false
}

// looksLikeNonWindows は PowerShell 実行エラー出力が Linux/非 Windows ホストを示すか判定する。
func looksLikeNonWindows(output string) bool {
lower := strings.ToLower(output)
for _, sig := range []string{
"command not found",
"not recognized as",
"powershell: not found",
"bash:",
"sh:",
"unknown command",
"no such file or directory",
"not supported on this platform",
"platformnotsupported",
} {
for _, sig := range nonWindowsMessageSignatures {
if strings.Contains(lower, sig) {
return true
}
}
for _, line := range strings.Split(lower, "\n") {
if hasNonWindowsShellPrefix(strings.TrimSpace(line)) {
return true
}
}
return false
}

Expand Down
11 changes: 11 additions & 0 deletions deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,17 @@ func TestLooksLikeNonWindows(t *testing.T) {
{"normal windows output — True", "True", false},
{"empty output", "", false},
{"windows error message", "The system cannot find the file specified.", false},
// シェル名の判定は行頭一致。"ssh:" や "…finish:" は "sh:" を含むが非 Windows ではない。
{"ssh error is not a shell prefix", "ssh: handshake failed: host key verification failed", false},
{"word ending in sh followed by colon", "Publish: failed to upload the artifact", false},
{"shell prefix mid-line is ignored", "Wrote log to C:\\tmp\\bash: notes.txt", false},
{"shell prefix on a later line", "#< CLIXML\nksh: powershell: cannot execute", true},
// 絶対パスで名乗るシェル。dash の "not found" は message 断片に一致しないため、
// basename 一致が無いと Windows 扱いのまま配置経路へ進んでしまう。
{"absolute path dash", "/bin/sh: 1: powershell: not found", true},
{"absolute path bash", "/usr/bin/bash: powershell: No such file", true},
{"absolute path non-shell binary", "/usr/bin/git: 'foo' is not a git command", false},
{"absolute path without colon", "/bin/sh is a shell", false},
}

for _, c := range cases {
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ toolchain go1.26.6

require (
github.com/kevinburke/ssh_config v1.6.0
golang.org/x/crypto v0.56.0
golang.org/x/term v0.45.0
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
)

require golang.org/x/sys v0.47.0 // indirect
require golang.org/x/sys v0.48.0 // indirect
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
Loading
Loading