Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions pkg/netstat/netstat_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,18 @@ func extractProcInfo(sktab []SockTabEntry) {
}

// doNetstat - collect information about network port status.
//
// If path does not exist (e.g. /proc/net/tcp6 on Linux hosts booted with
// ipv6.disable=1 or built without CONFIG_IPV6), this returns an empty slice
// and a nil error so callers can degrade gracefully instead of aborting the
// entire port scan. All other errors, including permission and parse
// failures, still propagate. See issue #705.
func doNetstat(path string, fn AcceptFn) ([]SockTabEntry, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
tabs, err := parseSocktab(f, fn)
Expand Down
116 changes: 116 additions & 0 deletions pkg/netstat/netstat_util_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package netstat

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)

type NetstatUtilTestSuite struct {
suite.Suite
}

func TestNetstatUtilSuite(t *testing.T) {
suite.Run(t, new(NetstatUtilTestSuite))
}

// TestDoNetstat_MissingFileReturnsEmpty verifies that a missing procfile (as
// seen on Linux hosts booted with ipv6.disable=1, where /proc/net/tcp6 does
// not exist) causes doNetstat to return an empty slice and nil error rather
// than propagating the os.IsNotExist error. This is the core regression
// guard for issue #705.
func (s *NetstatUtilTestSuite) TestDoNetstat_MissingFileReturnsEmpty() {
missing := filepath.Join(s.T().TempDir(), "does-not-exist")

entries, err := doNetstat(missing, NoopFilter)

assert.NoError(s.T(), err, "missing procfile must not error")
assert.Empty(s.T(), entries, "missing procfile must yield zero entries")
}

// TestDoNetstat_ParsesValidFile pins the happy-path behaviour of doNetstat so
// the IsNotExist fix cannot accidentally suppress successful parses. The
// synthetic content mimics the layout of /proc/net/tcp6: a header line
// followed by one listening IPv6 socket entry on [::]:22.
func (s *NetstatUtilTestSuite) TestDoNetstat_ParsesValidFile() {
// Listening on [::]:22 (ssh). Field layout mirrors /proc/net/tcp6:
// sl, local_address, rem_address, st, tx_queue:rx_queue, tr:tm->when,
// retrnsmt, uid, timeout, inode, ... parseSocktab discards the header
// and splits entries on whitespace, so single-spaced fields suffice.
// Local address is 32 hex chars (ipv6StrLen) : 4 hex port (0x0016=22).
// State 0x0A = Listen. UID is parsed as base-10.
fields := []string{
"0:",
"00000000000000000000000000000000:0016",
"00000000000000000000000000000000:0000",
"0A",
"00000000:00000000",
"00:00000000",
"00000000",
"0",
"0",
"12345",
"1",
"0000000000000000",
"100",
"0",
"0",
"10",
"0",
}
content := "header line discarded by parseSocktab\n" + strings.Join(fields, " ") + "\n"

path := filepath.Join(s.T().TempDir(), "tcp6")
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o600))

entries, err := doNetstat(path, func(e *SockTabEntry) bool {
return e.State == Listen
})

require.NoError(s.T(), err)
require.Len(s.T(), entries, 1)
assert.Equal(s.T(), uint16(22), entries[0].LocalAddr.Port)
assert.Equal(s.T(), Listen, entries[0].State)
assert.Equal(s.T(), uint32(0), entries[0].UID)
}

// TestDoNetstat_PropagatesNonNotExistOpenError verifies that the IsNotExist
// special case is surgical: other os.Open errors (here, permission denied)
// must still surface so real breakage is reported rather than silently
// swallowed. Skipped on Windows (different permission model) and when
// running as root (chmod 0 does not deny root on Linux).
func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesNonNotExistOpenError() {
if runtime.GOOS == "windows" {
s.T().Skip("permission semantics differ on Windows")
}
if os.Geteuid() == 0 {
s.T().Skip("root bypasses file-mode permission checks")
}

path := filepath.Join(s.T().TempDir(), "unreadable")
require.NoError(s.T(), os.WriteFile(path, []byte("irrelevant"), 0o000))
s.T().Cleanup(func() { _ = os.Chmod(path, 0o600) })

_, err := doNetstat(path, NoopFilter)

require.Error(s.T(), err, "permission errors must propagate")
assert.False(s.T(), os.IsNotExist(err), "error should not be IsNotExist")
}

// TestDoNetstat_PropagatesParseError ensures that once the file is
// successfully opened, malformed content still yields an error rather than
// being hidden by the IsNotExist fix.
func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesParseError() {
path := filepath.Join(s.T().TempDir(), "garbage")
require.NoError(s.T(), os.WriteFile(path, []byte("header\nnot enough fields here\n"), 0o600))

_, err := doNetstat(path, NoopFilter)

assert.Error(s.T(), err, "parser errors must propagate")
}
Loading