Skip to content

fix(git): verify SSH host keys against known_hosts by default#379

Open
Adam-D-Lewis wants to merge 1 commit into
mainfrom
fix/ssh-host-key-verification
Open

fix(git): verify SSH host keys against known_hosts by default#379
Adam-D-Lewis wants to merge 1 commit into
mainfrom
fix/ssh-host-key-verification

Conversation

@Adam-D-Lewis

Copy link
Copy Markdown
Member

Fixes #378

Problem

Git operations over SSH used InsecureIgnoreHostKey(), accepting any host key during clone/push of the GitOps repository — the source of truth for what ArgoCD deploys. A network MITM could impersonate the remote, capture pushed manifests, and serve tampered repo content.

Change

  • Verify host keys against standard known_hosts locations (SSH_KNOWN_HOSTS env, ~/.ssh/known_hosts, /etc/ssh/ssh_known_hosts) using go-git's built-in knownhosts support. Anyone who has ever SSH'd to their git forge already has the entry, so no new configuration is needed for typical use — this includes Windows, where the bundled OpenSSH client uses the same file layout.
  • Actionable failure messages that distinguish the two cases: an unknown host gets a remediation hint (connect once with ssh git@<host>, platform-neutral); a changed key gets an explicit man-in-the-middle warning with ssh-keygen -R recovery steps.
  • Explicit opt-out for ephemeral CI: insecure_skip_host_key_verification: true under git_repository.auth restores the old behavior where maintaining a known_hosts file is impractical. The insecure mode is now a visible per-config choice instead of a silent universal default.
  • Token/HTTPS auth is unchanged.

Tests

  • Package TestMain pins SSH_KNOWN_HOSTS to a hermetic file — existing tests silently depended on the machine's ~/.ssh/known_hosts and fail on a bare runner without this.
  • New coverage: missing known_hosts fails with guidance; opt-out succeeds without known_hosts; matching key accepted; unknown host rejected with remediation hint; changed key rejected with MITM warning.
  • go vet ./... and the full test suite pass.

Notes

  • skeema/knownhosts moves from indirect to direct dependency (already in the module graph via go-git).
  • examples/aws-config.yaml documents the new flag.
  • Possible follow-up: bundle well-known host keys for major forges as a fallback, mirroring ArgoCD's argocd-ssh-known-hosts-cm (which already protects the in-cluster sync path).

Git operations over SSH previously used InsecureIgnoreHostKey(),
accepting any host key and leaving the GitOps repository — the source
of truth for what ArgoCD deploys — open to man-in-the-middle
impersonation during clone and push.

Host keys are now verified against the standard known_hosts locations
(SSH_KNOWN_HOSTS, ~/.ssh/known_hosts, /etc/ssh/ssh_known_hosts), so
most users need no new configuration. Verification failures produce
platform-neutral, actionable errors that distinguish an unknown host
(with a hint to record its key) from a changed key (a possible MITM).

For ephemeral environments without a known_hosts file, the previous
behavior remains available as an explicit opt-in via
insecure_skip_host_key_verification: true under git auth config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Adam-D-Lewis Adam-D-Lewis added needs: review 👀 This PR is complete and ready for reviewing type: bug 🐛 Something isn't working labels Jun 10, 2026
@Adam-D-Lewis Adam-D-Lewis added needs: review 👀 This PR is complete and ready for reviewing type: bug 🐛 Something isn't working labels Jun 10, 2026

@dcmcand dcmcand left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. This is a clean, correct, well-tested security fix that fully resolves #378: host key verification is on by default now, with an explicit, well-named opt-out for ephemeral CI.

I verified the fix against the library internals, not just the tests: go-git builds its known_hosts callback on skeema/knownhosts under the hood, so the IsHostUnknown/IsHostKeyChanged classification here is reliable rather than string-matching. Tests pass, go build and go vet are clean, and go mod tidy leaves the module graph unchanged.

I left a few inline comments. None of them block this merge - Adam, they're yours to triage, and you can fix now, open follow-up issues, or ignore them at your discretion:

  • Two optional nits (em dash in the example config; the remediation hint degrades for non-standard SSH ports).
  • One non-blocking architectural thread about OTel instrumentation in config.go that's really a docs/convention decision for the maintainers.

One thing that isn't line-specific: this flips a default. SSH git ops now require a known_hosts entry, so anyone running NIC over SSH in a runner without one will see deploys fail until they add the entry or set insecure_skip_host_key_verification: true. That's the right posture, just worth a line in the release notes so upgraders aren't surprised.

Genuine kudos inline too - the library choice, the hermetic TestMain, and the opt-out naming were all done well.

Comment thread pkg/git/config.go
switch {
case err == nil:
return nil
case knownhosts.IsHostUnknown(err):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Kudos: this is the robust way to do it. go-git v5.16.4 builds its NewKnownHostsCallback on skeema/knownhosts internally, so reusing that same package's IsHostUnknown/IsHostKeyChanged here detects the *knownhosts.KeyError via errors.As instead of matching on message text. The three distinct, actionable errors (missing file, unknown host, and changed key with the MITM warning plus ssh-keygen -R recovery) are exactly what an operator needs when this fires.

Comment thread pkg/git/config_test.go
// TestMain points host key verification at a hermetic empty known_hosts file
// so package tests don't depend on the machine they run on. Individual tests
// override SSH_KNOWN_HOSTS via t.Setenv as needed.
func TestMain(m *testing.M) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice catch here. Pinning SSH_KNOWN_HOSTS to a hermetic empty file doesn't just isolate the new tests, it removes a latent dependency the existing tests had on the runner's ~/.ssh/known_hosts. This hardens CI on a bare runner, not just the code under test.

Comment thread pkg/git/config.go
// removing protection against man-in-the-middle attacks. Only intended
// for ephemeral environments (e.g. CI) where maintaining a known_hosts
// file is impractical. Has no effect on token (HTTPS) authentication.
InsecureSkipHostKeyVerification bool `yaml:"insecure_skip_host_key_verification,omitempty" json:"insecure_skip_host_key_verification,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good naming: the Insecure prefix mirrors golang.org/x/crypto/ssh.InsecureIgnoreHostKey, so the risk is legible at every call site, and omitempty keeps it out of normal configs.

Comment thread examples/aws-config.yaml
# token_env: GIT_TOKEN
# SSH host keys are verified against known_hosts (SSH_KNOWN_HOSTS,
# ~/.ssh/known_hosts, /etc/ssh/ssh_known_hosts). For ephemeral environments
# (e.g. CI) without a known_hosts file, verification can be disabled —

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this line uses an em dash. This project leans toward regular dashes or colons, so a one-character swap would match the house style. Non-blocking.

Comment thread pkg/git/config.go

return func(hostname string, remote net.Addr, key cryptossh.PublicKey) error {
err := callback(hostname, remote, key)
host := strings.TrimSuffix(hostname, ":22")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit (non-blocking): TrimSuffix(hostname, ":22") only strips the default port. For a self-hosted forge on a non-standard port (e.g. :2222), host stays git.example.com:2222, so the hints render as ssh git@git.example.com:2222 (ssh wants -p 2222) and ssh-keygen -R git.example.com:2222 (which won't match the [host]:port known_hosts format). Verification itself is unaffected; this is cosmetic in the guidance text only. net.SplitHostPort would make the hint correct for any port.

Comment thread pkg/git/config.go
// newHostKeyCallback returns a host key callback backed by the standard
// known_hosts files (SSH_KNOWN_HOSTS, ~/.ssh/known_hosts, /etc/ssh/ssh_known_hosts),
// wrapping verification failures with actionable guidance.
func newHostKeyCallback() (cryptossh.HostKeyCallback, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking architectural thread: the project convention is that new pkg/ functions are OpenTelemetry span-instrumented, but the established local pattern in this file is the opposite - every credential/config helper here (GetAuth, GetSSHKey, Validate, ...) is ctx-free and span-free, and only the operation methods in client_impl.go carry spans. newHostKeyCallback follows that local pattern faithfully. The clean resolution is probably to document a config.go exemption rather than thread ctx through the CredentialProvider interface, but that's a maintainer call. Flagging it so the gray area gets a decision, not asking you to change this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs: review 👀 This PR is complete and ready for reviewing type: bug 🐛 Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSH host key verification is disabled for all git operations (MITM exposure)

2 participants