chore: modernize CI and update Go toolchain

- Bump Go from 1.19 to 1.26 and update all dependencies
- Rewrite CI workflow with matrix strategy (Linux, macOS, Windows)
- Update GitHub Actions to current versions (checkout@v4, setup-go@v5)
- Update CodeQL actions from v1 to v3
- Fix cross-platform bug in mock/path.go (path.Join -> filepath.Join)
- Clean up dependabot config (weekly schedule, remove stale ignore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Allen Lane
2026-02-14 20:58:51 -05:00
parent cc85a4bdb1
commit 2a19755804
657 changed files with 49050 additions and 32001 deletions

View File

@@ -1 +0,0 @@
testdata/dos-lines eol=crlf

View File

@@ -0,0 +1 @@
/coverage.out

View File

@@ -5,5 +5,7 @@ Kevin Burke <kevin@burke.dev>
Mark Nevill <nev@improbable.io>
Scott Lessans <slessans@gmail.com>
Sergey Lukjanov <me@slukjanov.name>
Simon Josefsson <simon@josefsson.org>
sio2boss <sio2boss@users.noreply.github.com>
Wayne Ashley Berry <wayneashleyberry@gmail.com>
santosh653 <70637961+santosh653@users.noreply.github.com>

View File

@@ -1,7 +1,34 @@
# Changes
## Version 1.2
## Unreleased
- Implement Match support. Most of the Match spec is implemented, including
`Match host`, `Match originalhost`, `Match user`, `Match localuser`, and `Match
all`. `Match exec` is not yet implemented.
- Add SECURITY.md
- Add Dependabot configuration
## Version 1.4 (released August 19, 2025)
- Remove .gitattributes file (which was used to test different line endings, and
caused issues in some build environments). Store tests/dos-lines as CRLF in git
directly instead.
## Version 1.3 (released February 20, 2025)
- Add go.mod file (although this project has no dependencies).
- config: add UserSettings.ConfigFinder
- Various updates to CI and build environment
## Version 1.2 (released March 31, 2022)
- config: add DecodeBytes to directly read a byte array.
- Strip trailing whitespace from Host declarations and key/value pairs.
Previously, if a Host declaration or a value had trailing whitespace, that
whitespace would have been included as part of the value. This led to unexpected
consequences. For example:
@@ -17,3 +44,5 @@ unintuitive.
Instead, we strip the trailing whitespace in the configuration, which leads to
more intuitive behavior.
- Add fuzz tests.

View File

@@ -1,21 +1,21 @@
BUMP_VERSION := $(GOPATH)/bin/bump_version
STATICCHECK := $(GOPATH)/bin/staticcheck
WRITE_MAILMAP := $(GOPATH)/bin/write_mailmap
$(STATICCHECK):
go get honnef.co/go/tools/cmd/staticcheck
lint: $(STATICCHECK)
lint:
go vet ./...
$(STATICCHECK)
go run honnef.co/go/tools/cmd/staticcheck@latest ./...
test: lint
test:
@# the timeout helps guard against infinite recursion
go test -timeout=250ms ./...
race-test: lint
race-test:
go test -timeout=500ms -race ./...
coverage:
go test -trimpath -timeout=250ms -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
$(BUMP_VERSION):
go get -u github.com/kevinburke/bump_version

View File

@@ -82,11 +82,11 @@ file format.
[blog]: https://kev.inburke.com/kevin/more-comment-preserving-configuration-parsers/
[hostsfile]: https://github.com/kevinburke/hostsfile
## Donating
## Sponsorships
I don't get paid to maintain this project. Donations free up time to make
improvements to the library, and respond to bug reports. You can send donations
via Paypal's "Send Money" feature to kev@inburke.com. Donations are not tax
deductible in the USA.
Thank you very much to Tailscale and Indeed for sponsoring development of this
library. [Sponsors][sponsors] will get their names featured in the README.
You can also reach out about a consulting engagement: https://burke.services
[sponsors]: https://github.com/sponsors/kevinburke

63
vendor/github.com/kevinburke/ssh_config/SECURITY.md generated vendored Normal file
View File

@@ -0,0 +1,63 @@
# ssh_config security policy
## Supported Versions
As of September 2025, we're not aware of any security problems with ssh_config,
past or present. That said, we recommend always using the latest version of
ssh_config, and of the Go programming language, to ensure you have the most
recent security fixes.
## Reporting a Vulnerability
We take security vulnerabilities seriously. If you discover a security vulnerability in ssh_config, please report it responsibly by following these steps:
### How to Report
Please follow the instructions outlined here to report a vulnerability
privately: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability
If these are insufficient - it is not hard to find Kevin's contact information
on the Internet.
### What to Include
When reporting a vulnerability, please include a clear description of the vulnerability, steps to reproduce the issue, the potential impact, as well as any fixes you might have.
### Response Timeline
I'll try to acknowledge and patch the issue as quickly as possible.
Security advisories for this project will be published through:
- GitHub Security Advisories on this repository
- an Issue on this repository
- The project's release notes
- Go vulnerability databases
If you are using `ssh_config` and would like to be on a "pre-release"
distribution list for coordinating releases, please contact Kevin directly.
### Security Considerations
When using ssh_config, please be aware of these security considerations.
#### File System Access
This library reads SSH configuration files from the file system. Try to ensure
proper file permissions on SSH config files (typically 600 or 644), and be
cautious when parsing config files from untrusted sources.
#### Input Validation
The parser handles user-provided SSH configuration data. While we try our best
to parse the data appropriately, malformed configuration files could potentially
cause issues. Please try to validate and sanitize any configuration data from
external sources.
#### Dependencies
This project does not have any third party dependencies. Please try to keep your
Go version up to date.
## Acknowledgments
We appreciate security researchers and users who responsibly disclose vulnerabilities. Contributors who report valid security issues will be acknowledged in our security advisories (unless they prefer to remain anonymous).

View File

@@ -8,7 +8,7 @@
// the host name to match on ("example.com"), and the second argument is the key
// you want to retrieve ("Port"). The keywords are case insensitive.
//
// port := ssh_config.Get("myhost", "Port")
// port := ssh_config.Get("myhost", "Port")
//
// You can also manipulate an SSH config file and then print it or write it back
// to disk.
@@ -24,9 +24,6 @@
//
// // Write the cfg back to disk:
// fmt.Println(cfg.String())
//
// BUG: the Match directive is currently unsupported; parsing a config with
// a Match directive will trigger an error.
package ssh_config
import (
@@ -43,7 +40,7 @@ import (
"sync"
)
const version = "1.2"
const version = "1.5.0"
var _ = version
@@ -53,6 +50,8 @@ type configFinder func() string
// files are parsed and cached the first time Get() or GetStrict() is called.
type UserSettings struct {
IgnoreErrors bool
customConfig *Config
customConfigFinder configFinder
systemConfig *Config
systemConfigFinder configFinder
userConfig *Config
@@ -203,6 +202,13 @@ func (u *UserSettings) GetStrict(alias, key string) (string, error) {
if u.onceErr != nil && u.IgnoreErrors == false {
return "", u.onceErr
}
// TODO this is getting repetitive
if u.customConfig != nil {
val, err := findVal(u.customConfig, alias, key)
if err != nil || val != "" {
return val, err
}
}
val, err := findVal(u.userConfig, alias, key)
if err != nil || val != "" {
return val, err
@@ -228,6 +234,12 @@ func (u *UserSettings) GetAllStrict(alias, key string) ([]string, error) {
if u.onceErr != nil && u.IgnoreErrors == false {
return nil, u.onceErr
}
if u.customConfig != nil {
val, err := findAll(u.customConfig, alias, key)
if err != nil || val != nil {
return val, err
}
}
val, err := findAll(u.userConfig, alias, key)
if err != nil || val != nil {
return val, err
@@ -243,16 +255,38 @@ func (u *UserSettings) GetAllStrict(alias, key string) ([]string, error) {
return []string{}, nil
}
// ConfigFinder will invoke f to try to find a ssh config file in a custom
// location on disk, instead of in /etc/ssh or $HOME/.ssh. f should return the
// name of a file containing SSH configuration.
//
// ConfigFinder must be invoked before any calls to Get or GetStrict and panics
// if f is nil. Most users should not need to use this function.
func (u *UserSettings) ConfigFinder(f func() string) {
if f == nil {
panic("cannot call ConfigFinder with nil function")
}
u.customConfigFinder = f
}
func (u *UserSettings) doLoadConfigs() {
u.loadConfigs.Do(func() {
// can't parse user file, that's ok.
var filename string
var err error
if u.customConfigFinder != nil {
filename = u.customConfigFinder()
u.customConfig, err = parseFile(filename)
// IsNotExist should be returned because a user specified this
// function - not existing likely means they made an error
if err != nil {
u.onceErr = err
}
return
}
if u.userConfigFinder == nil {
filename = userConfigFinder()
} else {
filename = u.userConfigFinder()
}
var err error
u.userConfig, err = parseFile(filename)
//lint:ignore S1002 I prefer it this way
if err != nil && os.IsNotExist(err) == false {
@@ -351,9 +385,6 @@ func (c *Config) Get(alias, key string) (string, error) {
case *KV:
// "keys are case insensitive" per the spec
lkey := strings.ToLower(t.Key)
if lkey == "match" {
panic("can't handle Match directives")
}
if lkey == lowerKey {
return t.Value, nil
}
@@ -386,9 +417,6 @@ func (c *Config) GetAll(alias, key string) ([]string, error) {
case *KV:
// "keys are case insensitive" per the spec
lkey := strings.ToLower(t.Key)
if lkey == "match" {
panic("can't handle Match directives")
}
if lkey == lowerKey {
all = append(all, t.Value)
}
@@ -433,6 +461,9 @@ type Pattern struct {
// String prints the string representation of the pattern.
func (p Pattern) String() string {
if p.not {
return "!" + p.str
}
return p.str
}
@@ -491,7 +522,7 @@ func NewPattern(s string) (*Pattern, error) {
return &Pattern{str: s, regex: r, not: negated}, nil
}
// Host describes a Host directive and the keywords that follow it.
// Host describes a Host or Match directive and the keywords that follow it.
type Host struct {
// A list of host patterns that should match this host.
Patterns []*Pattern
@@ -506,6 +537,11 @@ type Host struct {
leadingSpace int // TODO: handle spaces vs tabs here.
// The file starts with an implicit "Host *" declaration.
implicit bool
// isMatch is true if this block was created by a Match directive.
isMatch bool
// matchKeyword stores the original text after "Match" (e.g. "Host" or
// "all") so we can round-trip correctly.
matchKeyword string
}
// Matches returns true if the Host matches for the given alias. For
@@ -537,17 +573,36 @@ func (h *Host) String() string {
//lint:ignore S1002 I prefer to write it this way
if h.implicit == false {
buf.WriteString(strings.Repeat(" ", int(h.leadingSpace)))
buf.WriteString("Host")
if h.hasEquals {
buf.WriteString(" = ")
} else {
buf.WriteString(" ")
}
for i, pat := range h.Patterns {
buf.WriteString(pat.String())
if i < len(h.Patterns)-1 {
if h.isMatch {
buf.WriteString("Match")
if h.hasEquals {
buf.WriteString(" = ")
} else {
buf.WriteString(" ")
}
buf.WriteString(h.matchKeyword)
if !strings.EqualFold(h.matchKeyword, "all") {
buf.WriteString(" ")
for i, pat := range h.Patterns {
buf.WriteString(pat.String())
if i < len(h.Patterns)-1 {
buf.WriteString(" ")
}
}
}
} else {
buf.WriteString("Host")
if h.hasEquals {
buf.WriteString(" = ")
} else {
buf.WriteString(" ")
}
for i, pat := range h.Patterns {
buf.WriteString(pat.String())
if i < len(h.Patterns)-1 {
buf.WriteString(" ")
}
}
}
buf.WriteString(h.spaceBeforeComment)
if h.EOLComment != "" {

View File

@@ -21,9 +21,9 @@ type sshParser struct {
type sshParserStateFn func() sshParserStateFn
// Formats and panics an error message based on a token
func (p *sshParser) raiseErrorf(tok *token, msg string, args ...interface{}) {
func (p *sshParser) raiseErrorf(tok *token, msg string) {
// TODO this format is ugly
panic(tok.Position.String() + ": " + fmt.Sprintf(msg, args...))
panic(tok.Position.String() + ": " + msg)
}
func (p *sshParser) raiseError(tok *token, err error) {
@@ -105,9 +105,7 @@ func (p *sshParser) parseKV() sshParserStateFn {
comment = tok.val
}
if strings.ToLower(key.val) == "match" {
// https://github.com/kevinburke/ssh_config/issues/6
p.raiseErrorf(val, "ssh_config: Match directive parsing is unsupported")
return nil
return p.parseMatch(val, hasEquals, comment)
}
if strings.ToLower(key.val) == "host" {
strPatterns := strings.Split(val.val, " ")
@@ -118,7 +116,7 @@ func (p *sshParser) parseKV() sshParserStateFn {
}
pat, err := NewPattern(strPatterns[i])
if err != nil {
p.raiseErrorf(val, "Invalid host pattern: %v", err)
p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err))
return nil
}
patterns = append(patterns, pat)
@@ -144,7 +142,7 @@ func (p *sshParser) parseKV() sshParserStateFn {
return nil
}
if err != nil {
p.raiseErrorf(val, "Error parsing Include directive: %v", err)
p.raiseErrorf(val, fmt.Sprintf("Error parsing Include directive: %v", err))
return nil
}
lastHost.Nodes = append(lastHost.Nodes, inc)
@@ -165,6 +163,73 @@ func (p *sshParser) parseKV() sshParserStateFn {
return p.parseStart
}
func (p *sshParser) parseMatch(val *token, hasEquals bool, comment string) sshParserStateFn {
// val.val contains everything after "Match ", e.g. "Host *.example.com"
// or "all".
trimmed := strings.TrimRightFunc(val.val, unicode.IsSpace)
spaceBeforeComment := val.val[len(trimmed):]
fields := strings.Fields(trimmed)
if len(fields) == 0 {
p.raiseErrorf(val, "ssh_config: Match directive requires at least one criterion")
return nil
}
criterion := strings.ToLower(fields[0])
switch criterion {
case "all":
// "Match all" is equivalent to "Host *" — matches everything.
p.config.Hosts = append(p.config.Hosts, &Host{
Patterns: []*Pattern{matchAll},
Nodes: make([]Node, 0),
EOLComment: comment,
spaceBeforeComment: spaceBeforeComment,
hasEquals: hasEquals,
isMatch: true,
matchKeyword: fields[0], // preserve original case
})
return p.parseStart
case "host":
patterns := make([]*Pattern, 0)
for _, s := range fields[1:] {
if s == "" {
continue
}
pat, err := NewPattern(s)
if err != nil {
p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err))
return nil
}
patterns = append(patterns, pat)
}
if len(patterns) == 0 {
p.raiseErrorf(val, "ssh_config: Match Host requires at least one pattern")
return nil
}
p.config.Hosts = append(p.config.Hosts, &Host{
Patterns: patterns,
Nodes: make([]Node, 0),
EOLComment: comment,
spaceBeforeComment: spaceBeforeComment,
hasEquals: hasEquals,
isMatch: true,
matchKeyword: fields[0], // preserve original case
})
return p.parseStart
case "exec":
// Match Exec runs arbitrary commands. Supporting it would allow
// untrusted SSH config files to execute code on the parsing
// machine. Reject it explicitly.
p.raiseErrorf(val, "ssh_config: Match Exec is not supported")
return nil
default:
p.raiseErrorf(val, fmt.Sprintf("ssh_config: unsupported Match criterion %q", criterion))
return nil
}
}
func (p *sshParser) parseComment() sshParserStateFn {
comment := p.getToken()
lastHost := p.config.Hosts[len(p.config.Hosts)-1]