-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathRuleParser.cs
More file actions
345 lines (306 loc) · 14.6 KB
/
Copy pathRuleParser.cs
File metadata and controls
345 lines (306 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
using System.Text;
namespace SCAScanner;
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
/// <summary>The subject a rule operates on.</summary>
public enum RuleType
{
File, // f:
Directory, // d:
Process, // p:
Command, // c:
Registry // r:
}
/// <summary>How the content value is matched.</summary>
public enum ContentOperator
{
Literal, // plain string containment
Regex, // r: prefix
Numeric // n: prefix + compare operator
}
public enum NumericComparison
{
LessThan, LessThanOrEqual, Equal, NotEqual, GreaterThanOrEqual, GreaterThan
}
// ---------------------------------------------------------------------------
// A single content condition (the part after "->", one segment split by &&)
// ---------------------------------------------------------------------------
public sealed class ContentCondition
{
/// <summary>True when prefixed with ! (pattern must NOT be found).</summary>
public bool Negated { get; init; }
public ContentOperator Operator { get; init; }
public string Pattern { get; init; } = string.Empty;
/// <summary>True if this content condition is malformed and cannot be evaluated.</summary>
public bool Invalid { get; init; }
/// <summary>Reason why this condition is invalid (if Invalid is true).</summary>
public string? InvalidReason { get; init; }
// Numeric only
public NumericComparison? NumericOp { get; init; }
public double? NumericValue { get; init; }
}
// ---------------------------------------------------------------------------
// A fully parsed rule string
// ---------------------------------------------------------------------------
public sealed class ParsedRule
{
/// <summary>True when the rule is prefixed with "not " or "!".</summary>
public bool Negated { get; init; }
public RuleType Type { get; init; }
/// <summary>File path, process name, command string, or registry key path.</summary>
public string Target { get; init; } = string.Empty;
public bool HasContentCheck { get; init; }
/// <summary>True if the rule definition is malformed/incomplete and cannot be executed.</summary>
public bool Invalid { get; init; }
/// <summary>Reason why the rule is invalid (if Invalid is true).</summary>
public string? InvalidReason { get; init; }
/// <summary>
/// Registry only: the value name in the 3-part format
/// r:KEY -> ValueName -> DataPattern
/// </summary>
public string? RegistryValueName { get; init; }
/// <summary>One or more conditions connected by && in the original rule string.</summary>
public List<ContentCondition> ContentConditions { get; init; } = [];
public string OriginalText { get; init; } = string.Empty;
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
public static class RuleParser
{
private static readonly string[] AndSeparator = [" && "];
private static readonly string[] ArrowSeparator = [" -> "];
/// <summary>
/// Parse a raw SCA rule string into a <see cref="ParsedRule"/>, expanding
/// any $variable references beforehand.
/// </summary>
public static ParsedRule Parse(string raw, PolicyVariables? variables = null)
{
string original = raw;
// Expand $variables
if (variables?.HasVariables == true)
foreach (var (k, v) in variables.Values)
raw = raw.Replace(k, v);
// ── Overall negation ─────────────────────────────────────────────
// Wazuh supports both "not " prefix and leading "!" (for non-registry)
bool negated = false;
if (raw.StartsWith("not ", StringComparison.OrdinalIgnoreCase))
{
negated = true;
raw = raw[4..];
}
// "!" negation only when NOT followed by a content-operator prefix
else if (raw.Length > 2 && raw[0] == '!' && raw[1] != 'r' && raw[1] != 'n')
{
negated = true;
raw = raw[1..];
}
// ── Rule type prefix ──────────────────────────────────────────────
RuleType type;
if (raw.StartsWith("f:")) { type = RuleType.File; raw = raw[2..]; }
else if (raw.StartsWith("d:")) { type = RuleType.Directory; raw = raw[2..]; }
else if (raw.StartsWith("p:")) { type = RuleType.Process; raw = raw[2..]; }
else if (raw.StartsWith("c:")) { type = RuleType.Command; raw = raw[2..]; }
else if (raw.StartsWith("r:")) { type = RuleType.Registry; raw = raw[2..]; }
else
return new ParsedRule
{
OriginalText = original,
Negated = negated,
Invalid = true,
InvalidReason = $"Unknown rule prefix in '{original}' — must start with f:, d:, p:, c:, or r:"
};
// ── Target vs content ─────────────────────────────────────────────
string target;
bool hasContentCheck = false;
string? registryValueName = null;
List<ContentCondition> conditions = [];
if (type == RuleType.Registry)
{
// Registry uses a 3-part format: KEY -> ValueName -> DataPattern
// All three parts separated by " -> "; DataPattern is optional.
string[] regParts = raw.Split(ArrowSeparator, 3, StringSplitOptions.None);
target = regParts[0].Trim();
if (regParts.Length >= 2)
{
hasContentCheck = true;
registryValueName = regParts[1].Trim();
}
if (regParts.Length >= 3)
{
// Multiple conditions separated by " && "
foreach (string seg in regParts[2].Split(AndSeparator, StringSplitOptions.None))
conditions.Add(ParseContentCondition(seg.Trim()));
}
}
else
{
// Split on the FIRST occurrence of " -> "
string[] parts = raw.Split(ArrowSeparator, 2, StringSplitOptions.None);
target = parts[0].Trim();
if (parts.Length == 2)
{
hasContentCheck = true;
// Each segment separated by " && " is an independent condition
foreach (string seg in parts[1].Split(AndSeparator, StringSplitOptions.None))
conditions.Add(ParseContentCondition(seg.Trim()));
}
}
// Propagate invalidity from content conditions
ContentCondition? invalidCondition = conditions.FirstOrDefault(c => c.Invalid);
return new ParsedRule
{
OriginalText = original,
Negated = negated,
Type = type,
Target = target,
HasContentCheck = hasContentCheck,
RegistryValueName = registryValueName,
ContentConditions = conditions,
Invalid = invalidCondition is not null,
InvalidReason = invalidCondition?.InvalidReason
};
}
// ── Content condition parser ──────────────────────────────────────────
private static ContentCondition ParseContentCondition(string s)
{
bool negated = s.StartsWith('!');
if (negated) s = s[1..];
if (s.StartsWith("r:"))
return new ContentCondition { Negated = negated, Operator = ContentOperator.Regex, Pattern = s[2..] };
if (s.StartsWith("n:"))
return ParseNumericCondition(negated, s[2..]);
return new ContentCondition { Negated = negated, Operator = ContentOperator.Literal, Pattern = s };
}
// Format: REGEX_WITH_(\d+) compare OPERATOR VALUE
private static ContentCondition ParseNumericCondition(bool negated, string s)
{
const string sep = " compare ";
int idx = s.IndexOf(sep, StringComparison.Ordinal);
if (idx < 0)
return new ContentCondition
{
Negated = negated, Operator = ContentOperator.Numeric, Pattern = s,
Invalid = true, InvalidReason = "Incomplete numeric condition: missing 'compare OPERATOR VALUE'"
};
string pattern = s[..idx];
string rest = s[(idx + sep.Length)..].Trim();
if (string.IsNullOrWhiteSpace(rest))
return new ContentCondition
{
Negated = negated, Operator = ContentOperator.Numeric, Pattern = pattern,
Invalid = true, InvalidReason = "Incomplete numeric condition: missing operator after 'compare'"
};
string[] comparison = rest.Split(' ', 2);
// Map the operator token in a single pass — null means unrecognised
NumericComparison? parsedOp = comparison[0] switch
{
"<" => NumericComparison.LessThan,
"<=" => NumericComparison.LessThanOrEqual,
"==" => NumericComparison.Equal,
"!=" => NumericComparison.NotEqual,
">=" => NumericComparison.GreaterThanOrEqual,
">" => NumericComparison.GreaterThan,
_ => (NumericComparison?)null
};
if (parsedOp is null)
return new ContentCondition
{
Negated = negated, Operator = ContentOperator.Numeric, Pattern = pattern,
Invalid = true,
InvalidReason = $"Incomplete numeric condition: '{comparison[0]}' is not a valid operator"
};
if (comparison.Length < 2)
return new ContentCondition
{
Negated = negated, Operator = ContentOperator.Numeric, Pattern = pattern,
Invalid = true, InvalidReason = "Incomplete numeric condition: missing value after operator"
};
if (!double.TryParse(comparison[1], out double v))
return new ContentCondition
{
Negated = negated, Operator = ContentOperator.Numeric, Pattern = pattern,
Invalid = true,
InvalidReason = $"Invalid numeric value '{comparison[1]}': must be a number"
};
return new ContentCondition
{
Negated = negated,
Operator = ContentOperator.Numeric,
Pattern = pattern,
NumericOp = parsedOp,
NumericValue = v
};
}
// =========================================================================
// Human-readable explanation (called BEFORE execution to explain intent)
// =========================================================================
public static string Explain(ParsedRule rule)
{
StringBuilder sb = new();
string neg = rule.Negated ? "NOT " : string.Empty;
if (!rule.HasContentCheck)
{
string verb = rule.Type switch
{
RuleType.File => $"{neg}File '{rule.Target}' must exist",
RuleType.Directory => $"{neg}Directory '{rule.Target}' must exist",
RuleType.Process => $"{neg}Process '{rule.Target}' must be running",
RuleType.Command => $"{neg}Command '{rule.Target}' must exit with code 0",
RuleType.Registry => $"{neg}Registry key '{rule.Target}' must exist",
_ => $"{neg}Check '{rule.Target}'"
};
return verb;
}
// Registry: value name exists but no data pattern
if (rule.Type == RuleType.Registry && rule.RegistryValueName is not null
&& rule.ContentConditions.Count == 0)
return $"{neg}Registry value '{rule.RegistryValueName}' must exist in key '{rule.Target}'";
string src = rule.Type switch
{
RuleType.Command => $"output of `{rule.Target}`",
RuleType.Registry => rule.RegistryValueName is not null
? $"value '{rule.RegistryValueName}' in key '{rule.Target}'"
: $"registry key '{rule.Target}'",
RuleType.File => $"'{rule.Target}'",
RuleType.Directory => $"files inside '{rule.Target}'",
_ => $"'{rule.Target}'"
};
if (rule.ContentConditions.Count == 1)
{
sb.Append(neg);
sb.Append(ExplainCondition(rule.ContentConditions[0], src));
}
else
{
sb.AppendLine($"{neg}A single line in {src} must satisfy ALL of:");
for (int i = 0; i < rule.ContentConditions.Count; i++)
sb.Append($"\n [{i + 1}] {ExplainCondition(rule.ContentConditions[i], src)}");
}
return sb.ToString();
}
private static string ExplainCondition(ContentCondition c, string source) =>
c.Operator switch
{
ContentOperator.Literal => $"{source} {(c.Negated ? "does NOT contain" : "contains")} literal: \"{c.Pattern}\"",
ContentOperator.Regex => $"{source} {(c.Negated ? "must NOT match" : "must match")} regex: `{c.Pattern}`",
ContentOperator.Numeric => ExplainNumeric(c, source),
_ => "unknown condition"
};
private static string ExplainNumeric(ContentCondition c, string source)
{
string opStr = c.NumericOp switch
{
NumericComparison.LessThan => "less than",
NumericComparison.LessThanOrEqual => "less than or equal to",
NumericComparison.Equal => "equal to",
NumericComparison.NotEqual => "not equal to",
NumericComparison.GreaterThanOrEqual => "greater than or equal to",
NumericComparison.GreaterThan => "greater than",
_ => "compared to"
};
string neg = c.Negated ? "NOT " : string.Empty;
return $"{neg}Numeric value captured by `{c.Pattern}` in {source} must be {opStr} {c.NumericValue}";
}
}