From 935327e61785928b615c5b726265adb8331c2ed4 Mon Sep 17 00:00:00 2001 From: shivasurya Date: Thu, 20 Nov 2025 17:32:42 -0500 Subject: [PATCH] feat: Add OR logic and wildcard matching for argument values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement advanced argument matching features to enable precise security rule detection. OR logic allows matching multiple acceptable values, while wildcard matching supports patterns like "0o7*" for octal permissions and "0.0.*" for IP addresses. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- sourcecode-parser/dsl/call_matcher.go | 126 ++++++- sourcecode-parser/dsl/call_matcher_test.go | 381 +++++++++++++++++++++ 2 files changed, 503 insertions(+), 4 deletions(-) diff --git a/sourcecode-parser/dsl/call_matcher.go b/sourcecode-parser/dsl/call_matcher.go index 3eed4c82..fa52c700 100644 --- a/sourcecode-parser/dsl/call_matcher.go +++ b/sourcecode-parser/dsl/call_matcher.go @@ -308,8 +308,10 @@ func (e *CallMatcherExecutor) matchesKeywordArguments(args []core.Argument) bool // - Boolean match: "True" == true, "False" == false // - Number match: "777" == 777, "0o777" == 0o777 (octal) // - Case-insensitive for booleans: "true" == "True" == "TRUE" +// - OR logic: ["w", "a", "w+"] matches any value in list +// - Wildcard matching: "0o7*" matches "0o777", "0o755", etc. // -// Performance: O(1) for single values. +// Performance: O(1) for single values, O(N) for OR logic with N values. func (e *CallMatcherExecutor) matchesArgumentValue(actual string, constraint ArgumentConstraint) bool { // Clean actual value (remove quotes, trim whitespace) actual = e.cleanValue(actual) @@ -317,11 +319,45 @@ func (e *CallMatcherExecutor) matchesArgumentValue(actual string, constraint Arg // Get expected value from constraint expected := constraint.Value - // Type-specific matching + // Handle list of values (OR logic) + if values, isList := expected.([]interface{}); isList { + return e.matchesAnyValue(actual, values, constraint.Wildcard) + } + + // Single value matching + return e.matchesSingleValue(actual, expected, constraint.Wildcard) +} + +// matchesAnyValue checks if actual matches any value in list (OR logic). +// +// Algorithm: +// 1. Iterate through all values in list +// 2. Return true if any value matches +// 3. Return false if no values match +// +// Performance: O(N) where N = number of values in list. +func (e *CallMatcherExecutor) matchesAnyValue(actual string, values []interface{}, wildcard bool) bool { + for _, v := range values { + if e.matchesSingleValue(actual, v, wildcard) { + return true + } + } + return false +} + +// matchesSingleValue checks if actual matches a single expected value. +// +// Algorithm: +// 1. Determine type of expected value +// 2. Call appropriate type-specific matcher +// 3. Return match result +// +// Performance: O(1) for non-wildcard, O(M) for wildcard where M = string length. +func (e *CallMatcherExecutor) matchesSingleValue(actual string, expected interface{}, wildcard bool) bool { switch v := expected.(type) { case string: - // String comparison - return e.normalizeValue(actual) == e.normalizeValue(v) + // String comparison with optional wildcard + return e.matchesString(actual, v, wildcard) case bool: // Boolean comparison @@ -341,6 +377,88 @@ func (e *CallMatcherExecutor) matchesArgumentValue(actual string, constraint Arg } } +// matchesString matches string values with optional wildcard support. +// +// Algorithm: +// 1. If wildcard is enabled, use wildcard matching +// 2. Otherwise, use normalized exact match +// +// Performance: O(1) for exact match, O(M) for wildcard where M = string length. +func (e *CallMatcherExecutor) matchesString(actual string, expected string, wildcard bool) bool { + if wildcard { + return e.matchesWildcard(actual, expected) + } + return e.normalizeValue(actual) == e.normalizeValue(expected) +} + +// matchesWildcard performs wildcard pattern matching. +// +// Supports: +// - * (zero or more characters) +// - ? (single character) +// +// Examples: +// - "0.0.*" matches "0.0.0.0", "0.0.255.255" +// - "0o7*" matches "0o777", "0o755" +// - "test-??.txt" matches "test-01.txt" +// +// Performance: O(M + N) where M = string length, N = pattern length. +func (e *CallMatcherExecutor) matchesWildcard(actual string, pattern string) bool { + // Normalize both strings + actual = e.normalizeValue(actual) + pattern = e.normalizeValue(pattern) + + return e.wildcardMatch(actual, pattern) +} + +// wildcardMatch implements wildcard matching algorithm. +// +// Algorithm: +// 1. Use two-pointer approach with backtracking +// 2. Handle * by recording position and trying to match rest +// 3. Handle ? by matching single character +// 4. Backtrack to last * if current match fails +// +// Performance: O(M + N) average case, O(M * N) worst case. +func (e *CallMatcherExecutor) wildcardMatch(str string, pattern string) bool { + sIdx, pIdx := 0, 0 + starIdx, matchIdx := -1, 0 + + for sIdx < len(str) { + if pIdx < len(pattern) { + if pattern[pIdx] == '*' { + // Record star position + starIdx = pIdx + matchIdx = sIdx + pIdx++ + continue + } else if pattern[pIdx] == '?' || pattern[pIdx] == str[sIdx] { + // Match single character + sIdx++ + pIdx++ + continue + } + } + + // No match, backtrack to last star + if starIdx != -1 { + pIdx = starIdx + 1 + matchIdx++ + sIdx = matchIdx + continue + } + + return false + } + + // Handle remaining stars in pattern + for pIdx < len(pattern) && pattern[pIdx] == '*' { + pIdx++ + } + + return pIdx == len(pattern) +} + // cleanValue removes surrounding quotes and whitespace from argument values. // // Examples: diff --git a/sourcecode-parser/dsl/call_matcher_test.go b/sourcecode-parser/dsl/call_matcher_test.go index 8d4199a6..c1f9be11 100644 --- a/sourcecode-parser/dsl/call_matcher_test.go +++ b/sourcecode-parser/dsl/call_matcher_test.go @@ -1098,3 +1098,384 @@ func TestPositionalArguments_BackwardCompatibility(t *testing.T) { assert.Len(t, matches, 1, "Old IR should still work") assert.Equal(t, "eval", matches[0].Target, "Old IR should match correctly") } + +// ========== OR Logic Tests ========== + +// TestMatchesArgumentValue_ORLogic_Strings tests OR logic with string values. +func TestMatchesArgumentValue_ORLogic_Strings(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"open"}, + PositionalArgs: map[string]ArgumentConstraint{ + "1": { + Value: []interface{}{"w", "a", "w+", "a+"}, + Wildcard: false, + }, + }, + }, + } + + tests := []struct { + name string + actualValue string + shouldMatch bool + }{ + {"Match w", "\"w\"", true}, + {"Match a", "\"a\"", true}, + {"Match w+", "\"w+\"", true}, + {"Match a+", "\"a+\"", true}, + {"No match r", "\"r\"", false}, + {"No match rb", "\"rb\"", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := &core.CallSite{ + Target: "open", + Arguments: []core.Argument{ + {Value: "\"/tmp/file\"", Position: 0}, + {Value: tt.actualValue, Position: 1}, + }, + } + result := executor.matchesArguments(cs) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// TestMatchesArgumentValue_ORLogic_Mixed tests OR logic with keyword arguments. +func TestMatchesArgumentValue_ORLogic_Mixed(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"config.set"}, + KeywordArgs: map[string]ArgumentConstraint{ + "mode": { + Value: []interface{}{"debug", "development", "test"}, + Wildcard: false, + }, + }, + }, + } + + // Should match any of the three values + cs1 := &core.CallSite{ + Target: "config.set", + Arguments: []core.Argument{ + {Value: "mode=debug", Position: 0}, + }, + } + assert.True(t, executor.matchesArguments(cs1)) + + cs2 := &core.CallSite{ + Target: "config.set", + Arguments: []core.Argument{ + {Value: "mode=development", Position: 0}, + }, + } + assert.True(t, executor.matchesArguments(cs2)) + + // Should not match production + cs3 := &core.CallSite{ + Target: "config.set", + Arguments: []core.Argument{ + {Value: "mode=production", Position: 0}, + }, + } + assert.False(t, executor.matchesArguments(cs3)) +} + +// TestMatchesArgumentValue_ORLogic_Numbers tests OR logic with numeric values. +func TestMatchesArgumentValue_ORLogic_Numbers(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"chmod"}, + PositionalArgs: map[string]ArgumentConstraint{ + "1": { + Value: []interface{}{float64(511), float64(493), float64(448)}, // 0o777, 0o755, 0o700 + Wildcard: false, + }, + }, + }, + } + + tests := []struct { + name string + actualValue string + shouldMatch bool + }{ + {"Match 0o777", "0o777", true}, + {"Match 0o755", "0o755", true}, + {"Match 0o700", "0o700", true}, + {"No match 0o644", "0o644", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := &core.CallSite{ + Target: "chmod", + Arguments: []core.Argument{ + {Value: "/tmp/file", Position: 0}, + {Value: tt.actualValue, Position: 1}, + }, + } + result := executor.matchesArguments(cs) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// ========== Wildcard Matching Tests ========== + +// TestMatchesWildcard_BasicPatterns tests basic wildcard patterns. +func TestMatchesWildcard_BasicPatterns(t *testing.T) { + executor := &CallMatcherExecutor{} + + tests := []struct { + name string + actual string + pattern string + shouldMatch bool + }{ + // Star wildcard + {"Star prefix", "0.0.0.0", "0.0.*", true}, + {"Star suffix", "0.0.0.0", "*.0.0", true}, + {"Star middle", "test-file.txt", "test-*", true}, + {"Star all", "anything", "*", true}, + {"Star no match", "192.168.1.1", "10.*", false}, + + // Question wildcard + {"Question single", "abc", "a?c", true}, + {"Question multiple", "test", "t??t", true}, + {"Question no match", "abc", "a?d", false}, + + // Combined wildcards + {"Star and question", "test-01.txt", "test-??.txt", true}, + {"Complex pattern", "192.168.1.100", "192.*.1.*", true}, + + // Exact match (no wildcards) + {"Exact match", "exact", "exact", true}, + {"Exact no match", "exact", "different", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := executor.matchesWildcard(tt.actual, tt.pattern) + assert.Equal(t, tt.shouldMatch, result, + "Pattern: %s, Actual: %s", tt.pattern, tt.actual) + }) + } +} + +// TestMatchesWildcard_OctalPatterns tests wildcard matching with octal values. +func TestMatchesWildcard_OctalPatterns(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"chmod"}, + PositionalArgs: map[string]ArgumentConstraint{ + "1": { + Value: "0o7*", // Match 0o7XX (world-writable) + Wildcard: true, + }, + }, + }, + } + + tests := []struct { + name string + actualValue string + shouldMatch bool + }{ + {"0o777", "0o777", true}, + {"0o755", "0o755", true}, + {"0o700", "0o700", true}, + {"0o644", "0o644", false}, // Doesn't start with 7 + {"0o600", "0o600", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := &core.CallSite{ + Target: "chmod", + Arguments: []core.Argument{ + {Value: "\"/tmp/file\"", Position: 0}, + {Value: tt.actualValue, Position: 1}, + }, + } + result := executor.matchesArguments(cs) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// TestMatchesWildcard_IPAddressPatterns tests wildcard matching with IP addresses. +func TestMatchesWildcard_IPAddressPatterns(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"socket.bind"}, + PositionalArgs: map[string]ArgumentConstraint{ + "0": { + Value: "0.0.*", // Match 0.0.X.X + Wildcard: true, + }, + }, + }, + } + + tests := []struct { + name string + ip string + shouldMatch bool + }{ + {"0.0.0.0", "\"0.0.0.0\"", true}, + {"0.0.0.1", "\"0.0.0.1\"", true}, + {"0.0.255.255", "\"0.0.255.255\"", true}, + {"127.0.0.1", "\"127.0.0.1\"", false}, + {"192.168.1.1", "\"192.168.1.1\"", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := &core.CallSite{ + Target: "socket.bind", + Arguments: []core.Argument{ + {Value: tt.ip, Position: 0}, + }, + } + result := executor.matchesArguments(cs) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// TestMatchesArgumentValue_ORLogicWithWildcards tests combined OR + wildcard. +func TestMatchesArgumentValue_ORLogicWithWildcards(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"yaml.load"}, + KeywordArgs: map[string]ArgumentConstraint{ + "Loader": { + Value: []interface{}{"*Loader", "*UnsafeLoader"}, + Wildcard: true, + }, + }, + }, + } + + tests := []struct { + name string + loader string + shouldMatch bool + }{ + {"FullLoader", "Loader=FullLoader", true}, + {"UnsafeLoader", "Loader=UnsafeLoader", true}, + {"yaml.UnsafeLoader", "Loader=yaml.UnsafeLoader", true}, + {"SafeLoader", "Loader=SafeLoader", true}, + {"BaseLoader", "Loader=BaseLoader", true}, + {"None", "Loader=None", false}, // Doesn't end with Loader or UnsafeLoader + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := &core.CallSite{ + Target: "yaml.load", + Arguments: []core.Argument{ + {Value: "data", Position: 0}, + {Value: tt.loader, Position: 1}, + }, + } + result := executor.matchesArguments(cs) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// TestMatchesArgumentValue_EdgeCases tests edge cases for value matching. +func TestMatchesArgumentValue_EdgeCases(t *testing.T) { + executor := &CallMatcherExecutor{} + + tests := []struct { + name string + actual string + expected interface{} + wildcard bool + shouldMatch bool + }{ + // Empty values + {"Empty actual", "", "value", false, false}, + {"Empty pattern", "value", "", false, false}, + {"Both empty", "", "", false, true}, + + // Wildcard edge cases + {"Only star", "anything", "*", true, true}, + {"Multiple stars", "test", "***", true, true}, + {"Star at end", "prefix", "prefix*", true, true}, + {"Star at start", "suffix", "*suffix", true, true}, + + // Question mark edge cases + {"Single question", "a", "?", true, true}, + {"Question too many", "ab", "???", true, false}, + {"Question too few", "abc", "?", true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := executor.matchesSingleValue(tt.actual, tt.expected, tt.wildcard) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// TestWildcardMatch_ComplexPatterns tests complex wildcard scenarios. +func TestWildcardMatch_ComplexPatterns(t *testing.T) { + executor := &CallMatcherExecutor{} + + tests := []struct { + name string + str string + pattern string + shouldMatch bool + }{ + // Multiple stars + {"Two stars", "abcdef", "*c*f", true}, + {"Three stars", "test-file-123.txt", "*-*-*.*", true}, + + // Mixed wildcards + {"Star and questions", "test-01.txt", "test-??.*", true}, + {"Question and star", "file123.txt", "file???.txt", true}, + + // Edge patterns + {"Empty pattern", "test", "", false}, + {"Pattern longer", "ab", "abc", false}, + {"String longer", "abc", "ab", false}, + + // Special cases + {"All questions", "test", "????", true}, + {"All stars", "anything", "***", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := executor.wildcardMatch(tt.str, tt.pattern) + assert.Equal(t, tt.shouldMatch, result) + }) + } +} + +// Benchmark wildcard matching. +func BenchmarkWildcardMatch_Simple(b *testing.B) { + executor := &CallMatcherExecutor{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + executor.wildcardMatch("192.168.1.100", "192.*.1.*") + } +} + +func BenchmarkWildcardMatch_Complex(b *testing.B) { + executor := &CallMatcherExecutor{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + executor.wildcardMatch("test-file-12345.txt", "test-*-?????.txt") + } +}