Skip to content

Commit 4bafa18

Browse files
committed
glob parser + tests ,address pr comments
1 parent 5081dd0 commit 4bafa18

File tree

8 files changed

+310
-48
lines changed

8 files changed

+310
-48
lines changed

providers/os/resources/sshd.go

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -172,41 +172,37 @@ func (s *mqlSshdConfig) parse(file *mqlFile) error {
172172
file.Path.Data: file,
173173
}
174174
var allContents strings.Builder
175-
globPathContent := func(glob string) (string, []string, error) {
176-
paths, err := s.expandGlob(glob)
177-
if err != nil {
178-
return "", nil, err
179-
}
180-
181-
var content strings.Builder
182-
for _, path := range paths {
183-
file, ok := filesIdx[path]
184-
if !ok {
185-
raw, err := CreateResource(s.MqlRuntime, "file", map[string]*llx.RawData{
186-
"path": llx.StringData(path),
187-
})
188-
if err != nil {
189-
return "", nil, err
190-
}
191-
file = raw.(*mqlFile)
192-
filesIdx[path] = file
193-
}
194175

195-
fileContent := file.GetContent()
196-
if fileContent.Error != nil {
197-
return "", nil, fileContent.Error
176+
// Function to get file content by path
177+
fileContent := func(path string) (string, error) {
178+
file, ok := filesIdx[path]
179+
if !ok {
180+
raw, err := CreateResource(s.MqlRuntime, "file", map[string]*llx.RawData{
181+
"path": llx.StringData(path),
182+
})
183+
if err != nil {
184+
return "", err
198185
}
186+
file = raw.(*mqlFile)
187+
filesIdx[path] = file
188+
}
199189

200-
content.WriteString(fileContent.Data)
201-
content.WriteString("\n")
190+
fileContent := file.GetContent()
191+
if fileContent.Error != nil {
192+
return "", fileContent.Error
202193
}
203194

204-
res := content.String()
205-
allContents.WriteString(res)
206-
return res, paths, nil
195+
content := fileContent.Data + "\n"
196+
allContents.WriteString(content)
197+
return fileContent.Data, nil
198+
}
199+
200+
// Function to expand glob patterns
201+
globExpand := func(glob string) ([]string, error) {
202+
return s.expandGlob(glob)
207203
}
208204

209-
matchBlocks, err := sshd.ParseBlocks(file.Path.Data, globPathContent)
205+
matchBlocks, err := sshd.ParseBlocksWithGlob(file.Path.Data, fileContent, globExpand)
210206
// TODO: check if not ready on I/O
211207
if err != nil {
212208
s.Params = plugin.TValue[map[string]any]{Error: err, State: plugin.StateIsSet | plugin.StateIsNull}

providers/os/resources/sshd/params.go

Lines changed: 141 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,25 +112,136 @@ func mergeIncludedBlocks(matchConditions map[string]*MatchBlock, blocks MatchBlo
112112
}
113113
}
114114

115-
type globPathContentFunc func(string) (content string, paths []string, err error)
115+
type (
116+
fileContentFunc func(string) (content string, err error)
117+
globExpandFunc func(string) (paths []string, err error)
118+
)
119+
120+
// ParseBlocks parses a single SSH config file and returns the match blocks.
121+
// The filePath should be the actual file path (not a glob pattern).
122+
// For Include directives with glob patterns, use ParseBlocksWithGlob instead.
123+
func ParseBlocks(filePath string, content string) (MatchBlocks, error) {
124+
curBlock := &MatchBlock{
125+
Criteria: "",
126+
Params: map[string]any{},
127+
Context: Context{
128+
Path: filePath,
129+
Range: llx.NewRange(),
130+
curLine: 1,
131+
},
132+
}
133+
matchConditions := map[string]*MatchBlock{
134+
"": curBlock,
135+
}
136+
137+
lines := strings.Split(content, "\n")
138+
for curLineIdx, textLine := range lines {
139+
l, err := ParseLine([]rune(textLine))
140+
if err != nil {
141+
return nil, err
142+
}
143+
144+
key := l.key
145+
if key == "" {
146+
continue
147+
}
148+
149+
// handle lower case entries and use proper ssh camel case
150+
if sshKey, ok := SSH_Keywords[strings.ToLower(key)]; ok {
151+
key = sshKey
152+
}
153+
154+
if key == "Include" {
155+
// Include directives are handled by ParseBlocksWithGlob
156+
// This function only parses single files
157+
log.Warn().Str("file", filePath).Msg("Include directive found in single-file parser, use ParseBlocksWithGlob instead")
158+
continue
159+
}
160+
161+
if key == "Match" {
162+
// wrap up context on the previous block
163+
curBlock.Context.Range = curBlock.Context.Range.AddLineRange(uint32(curBlock.Context.curLine), uint32(curLineIdx))
164+
curBlock.Context.curLine = curLineIdx
116165

117-
func ParseBlocks(rootPath string, globPathContent globPathContentFunc) (MatchBlocks, error) {
118-
content, actualPaths, err := globPathContent(rootPath)
166+
// This key is stored in the condition of each block and can be accessed there.
167+
condition := l.args
168+
if b, ok := matchConditions[condition]; ok {
169+
curBlock = b
170+
} else {
171+
curBlock = &MatchBlock{
172+
Criteria: condition,
173+
Params: map[string]any{},
174+
Context: Context{
175+
curLine: curLineIdx + 1,
176+
Path: filePath,
177+
Range: llx.NewRange(),
178+
},
179+
}
180+
matchConditions[condition] = curBlock
181+
}
182+
continue
183+
}
184+
185+
setParam(curBlock.Params, key, l.args)
186+
}
187+
188+
keys := sortx.Keys(matchConditions)
189+
res := make([]*MatchBlock, len(keys))
190+
i := 0
191+
for _, key := range keys {
192+
res[i] = matchConditions[key]
193+
i++
194+
}
195+
196+
curBlock.Context.Range = curBlock.Context.Range.AddLineRange(uint32(curBlock.Context.curLine), uint32(len(lines)))
197+
198+
return res, nil
199+
}
200+
201+
// ParseBlocksWithGlob parses SSH config files, expanding glob patterns in Include directives.
202+
// It expands globs and calls ParseBlocksWithGlobRecursive for each matched file individually,
203+
func ParseBlocksWithGlob(rootPath string, fileContent fileContentFunc, globExpand globExpandFunc) (MatchBlocks, error) {
204+
// First, expand the root path if it's a glob
205+
paths, err := globExpand(rootPath)
119206
if err != nil {
120207
return nil, err
121208
}
122209

123-
// This ensures that glob patterns use the actual file path instead of the glob pattern
124-
actualPath := rootPath
125-
if len(actualPaths) > 0 {
126-
actualPath = actualPaths[0]
210+
// If no paths matched, return empty blocks
211+
if len(paths) == 0 {
212+
return MatchBlocks{}, nil
127213
}
128214

215+
// Parse each file individually and collect all blocks
216+
// Each file maintains its own context (path, line numbers)
217+
var allBlocks MatchBlocks
218+
219+
for _, path := range paths {
220+
content, err := fileContent(path)
221+
if err != nil {
222+
log.Warn().Err(err).Str("path", path).Msg("unable to read file")
223+
continue
224+
}
225+
226+
blocks, err := ParseBlocksWithGlobRecursive(path, content, fileContent, globExpand)
227+
if err != nil {
228+
log.Warn().Err(err).Str("path", path).Msg("unable to parse file")
229+
continue
230+
}
231+
232+
allBlocks = append(allBlocks, blocks...)
233+
}
234+
235+
return allBlocks, nil
236+
}
237+
238+
// ParseBlocksWithGlobRecursive parses a single file and recursively handles Include directives.
239+
func ParseBlocksWithGlobRecursive(filePath string, content string, fileContent fileContentFunc, globExpand globExpandFunc) (MatchBlocks, error) {
129240
curBlock := &MatchBlock{
130241
Criteria: "",
131242
Params: map[string]any{},
132243
Context: Context{
133-
Path: actualPath,
244+
Path: filePath,
134245
Range: llx.NewRange(),
135246
curLine: 1,
136247
},
@@ -158,15 +269,31 @@ func ParseBlocks(rootPath string, globPathContent globPathContentFunc) (MatchBlo
158269

159270
if key == "Include" {
160271
// FIXME: parse multi-keys properly
161-
paths := strings.Split(l.args, " ")
272+
includePaths := strings.Split(l.args, " ")
162273

163-
for _, path := range paths {
164-
subBlocks, err := ParseBlocks(path, globPathContent)
274+
for _, includePath := range includePaths {
275+
// Expand glob pattern if present
276+
expandedPaths, err := globExpand(includePath)
165277
if err != nil {
166-
log.Warn().Err(err).Msg("unable to parse Include directive")
278+
log.Warn().Err(err).Str("path", includePath).Msg("unable to expand Include directive")
167279
continue
168280
}
169-
mergeIncludedBlocks(matchConditions, subBlocks, curBlock.Criteria)
281+
282+
// Parse each matched file individually
283+
for _, expandedPath := range expandedPaths {
284+
subContent, err := fileContent(expandedPath)
285+
if err != nil {
286+
log.Warn().Err(err).Str("path", expandedPath).Msg("unable to read included file")
287+
continue
288+
}
289+
290+
subBlocks, err := ParseBlocksWithGlobRecursive(expandedPath, subContent, fileContent, globExpand)
291+
if err != nil {
292+
log.Warn().Err(err).Str("path", expandedPath).Msg("unable to parse included file")
293+
continue
294+
}
295+
mergeIncludedBlocks(matchConditions, subBlocks, curBlock.Criteria)
296+
}
170297
}
171298
continue
172299
}
@@ -187,7 +314,7 @@ func ParseBlocks(rootPath string, globPathContent globPathContentFunc) (MatchBlo
187314
Params: map[string]any{},
188315
Context: Context{
189316
curLine: curLineIdx + 1,
190-
Path: actualPath,
317+
Path: filePath,
191318
Range: llx.NewRange(),
192319
},
193320
}

providers/os/resources/sshd/params_test.go

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ func TestSSHParser(t *testing.T) {
1515
raw, err := os.ReadFile("./testdata/sshd_config")
1616
require.NoError(t, err)
1717

18-
sshParams, err := ParseBlocks(string(raw), func(s string) (string, []string, error) {
19-
return string(raw), []string{s}, nil
20-
})
18+
sshParams, err := ParseBlocks("./testdata/sshd_config", string(raw))
2119
if err != nil {
2220
t.Fatalf("cannot request file %v", err)
2321
}
@@ -34,9 +32,7 @@ func TestSSHParseCaseInsensitive(t *testing.T) {
3432
raw, err := os.ReadFile("./testdata/case_insensitive")
3533
require.NoError(t, err)
3634

37-
sshParams, err := ParseBlocks(string(raw), func(s string) (string, []string, error) {
38-
return string(raw), []string{s}, nil
39-
})
35+
sshParams, err := ParseBlocks("./testdata/case_insensitive", string(raw))
4036
if err != nil {
4137
t.Fatalf("cannot request file %v", err)
4238
}
@@ -47,3 +43,110 @@ func TestSSHParseCaseInsensitive(t *testing.T) {
4743
assert.Equal(t, "any", sshParams[0].Params["AddressFamily"])
4844
assert.Equal(t, "0.0.0.0", sshParams[0].Params["ListenAddress"])
4945
}
46+
47+
func TestSSHParseWithGlob(t *testing.T) {
48+
// Test that glob patterns in Include directives are expanded correctly
49+
// and that each file maintains its own context
50+
51+
fileContent := func(path string) (string, error) {
52+
content, err := os.ReadFile(path)
53+
if err != nil {
54+
return "", err
55+
}
56+
return string(content), nil
57+
}
58+
59+
globExpand := func(glob string) ([]string, error) {
60+
// Simple glob expansion for test - in real code this would use filepath.Glob
61+
// For this test, we'll manually handle the known patterns
62+
var paths []string
63+
if glob == "conf.d/*.conf" {
64+
paths = []string{
65+
"./testdata/conf.d/01_mondoo.conf",
66+
"./testdata/conf.d/02_security.conf",
67+
}
68+
} else if glob == "subdir/01_*.conf" {
69+
paths = []string{
70+
"./testdata/subdir/01_custom.conf",
71+
}
72+
} else if glob == "subdir/02_*.conf" {
73+
paths = []string{
74+
"./testdata/subdir/02_additional.conf",
75+
}
76+
} else if glob == "./testdata/sshd_config_with_include" {
77+
paths = []string{"./testdata/sshd_config_with_include"}
78+
} else {
79+
// Try to read as a single file
80+
if _, err := os.Stat(glob); err == nil {
81+
paths = []string{glob}
82+
}
83+
}
84+
return paths, nil
85+
}
86+
87+
blocks, err := ParseBlocksWithGlob("./testdata/sshd_config_with_include", fileContent, globExpand)
88+
require.NoError(t, err)
89+
assert.NotNil(t, blocks)
90+
91+
// Verify that we have blocks from multiple files
92+
// The main file should have blocks, and included files should also contribute
93+
assert.Greater(t, len(blocks), 0, "should have at least one block")
94+
95+
// Check that params from included files are present
96+
// Find the default block (empty criteria)
97+
var defaultBlock *MatchBlock
98+
for _, block := range blocks {
99+
if block.Criteria == "" {
100+
defaultBlock = block
101+
break
102+
}
103+
}
104+
require.NotNil(t, defaultBlock, "should have a default block")
105+
106+
// Verify params from main file
107+
assert.Equal(t, "22", defaultBlock.Params["Port"])
108+
assert.Equal(t, "any", defaultBlock.Params["AddressFamily"])
109+
assert.Equal(t, "yes", defaultBlock.Params["UsePAM"])
110+
assert.Equal(t, "yes", defaultBlock.Params["X11Forwarding"])
111+
112+
// Verify params from included files (conf.d/*.conf)
113+
assert.Equal(t, "no", defaultBlock.Params["PermitRootLogin"])
114+
assert.Equal(t, "yes", defaultBlock.Params["PasswordAuthentication"])
115+
assert.Equal(t, "3", defaultBlock.Params["MaxAuthTries"])
116+
assert.Equal(t, "30", defaultBlock.Params["LoginGraceTime"])
117+
118+
// Verify params from subdirectory files
119+
assert.Contains(t, defaultBlock.Params["Ciphers"], "aes256-gcm@openssh.com")
120+
assert.Contains(t, defaultBlock.Params["MACs"], "hmac-sha2-256-etm@openssh.com")
121+
122+
// Verify that blocks have correct file paths in their context
123+
// Check that we have blocks from different files
124+
filePaths := make(map[string]bool)
125+
for _, block := range blocks {
126+
filePaths[block.Context.Path] = true
127+
}
128+
129+
// Should have blocks from main file and included files
130+
assert.Greater(t, len(filePaths), 1, "should have blocks from multiple files")
131+
132+
// Verify Match blocks from included files
133+
var sftpBlock *MatchBlock
134+
var adminBlock *MatchBlock
135+
for _, block := range blocks {
136+
if block.Criteria == "Group sftp-users" {
137+
sftpBlock = block
138+
}
139+
if block.Criteria == "User admin" {
140+
adminBlock = block
141+
}
142+
}
143+
144+
require.NotNil(t, sftpBlock, "should have sftp-users match block")
145+
assert.Equal(t, "no", sftpBlock.Params["AllowTcpForwarding"])
146+
assert.Contains(t, sftpBlock.Context.Path, "01_mondoo.conf")
147+
148+
require.NotNil(t, adminBlock, "should have admin user match block")
149+
assert.Equal(t, "yes", adminBlock.Params["PermitRootLogin"])
150+
assert.Equal(t, "no", adminBlock.Params["PasswordAuthentication"])
151+
assert.Contains(t, adminBlock.Context.Path, "02_security.conf")
152+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# First included config file
2+
PermitRootLogin no
3+
PasswordAuthentication yes
4+
5+
Match Group sftp-users
6+
AllowTcpForwarding no
7+
ChrootDirectory /home/%u
8+

0 commit comments

Comments
 (0)