Engine: Sagan, built from quadrantsec/sagan@3b9b0fa
Affects: any rule writing pcre:!"/pattern/"
Impact: the rule loads without a warning and asserts what it meant to forbid
Summary
content supports negation: content:!"foo" is parsed by Check_Content_Not and stored in content_not[]. pcre has no equivalent. The parser hands the quoted pattern straight to Between_Quotes without ever looking for a leading !, no pcre_not field exists anywhere in the rule structure, and PcreS() counts matches with no notion of negation.
The ! is therefore dropped silently, and pcre:!"/x/" behaves exactly like pcre:"/x/". A rule written to exclude something requires it instead.
What was measured
Sagan built from 3b9b0fa, one reduced rule, one event user bob failed login from program sshd:
| Rule |
Result |
Expected |
program:sshd; |
alert |
alert |
program:sshd; pcre:!"/ZZZ_absent/"; |
no alert |
alert |
program:sshd; pcre:!"/failed/"; |
alert |
no alert |
program:sshd; pcre:"/failed/"; |
alert |
alert |
Rows two and three are the defect, and they are each other's mirror: the rule stays silent exactly when the forbidden pattern is absent, and fires exactly when it is present.
The same holds with a content beside it, and with two negated patterns, so it is not an interaction with anything else in the rule.
Where it comes from
src/rules.c, the pcre block at line 3013, takes the argument and calls
Between_Quotes(arg, tmp2, sizeof(tmp2), ruleset_fullname, linecount, false);
with no Check_Content_Not(arg) beside it, unlike the content block near line2803 which does exactly that. Nothing else in the block inspects the !.
src/pcre-s.c, PcreS() at line 39, is the whole evaluation:
for(z=0; z<rulestruct[rule_position].pcre_count; z++)
{
rc = pcre_exec( ... );
if ( rc > 0 )
{
match++;
}
}
if ( match == rulestruct[rule_position].pcre_count )
{
return(true);
}
return(false);
Every pattern must match for the rule to pass. There is no per-pattern flag to invert, and grep -rn pcre_not src/ returns nothing.
json_pcre is worth checking at the same time: it has its own evaluation path and this issue does not cover it.
Suggested directions
Either add the negation, mirroring content: a pcre_not[] flag set by Check_Content_Not in the parser, and PcreS() counting a non-match as a match for those entries. Or reject the syntax at load time, so a rule that cannot mean what it says is refused rather than quietly inverted.
The second is a smaller change and arguably the safer one: silently inverting a security rule's exclusion is worse than refusing to start.
Corpus impact
One rule uses it: windows-powershell.rules sid 5007143, "Suspicious FromBase64String Encoded Commands". It carries
pcre:!"/C:\\Packages\\Plugins\\Microsoft\.Powershell\.DSC\\[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\\bin\\Azure\.psm1/"
meaning to exclude a legitimate Azure DSC module path. As the engine reads it, the rule fires only on that path: it alerts on precisely the activity it was written to ignore, and on nothing else.
No rules change is proposed here. The rule is correct as written; the engine is not.
Engine: Sagan, built from
quadrantsec/sagan@3b9b0faAffects: any rule writing
pcre:!"/pattern/"Impact: the rule loads without a warning and asserts what it meant to forbid
Summary
contentsupports negation:content:!"foo"is parsed byCheck_Content_Notand stored incontent_not[].pcrehas no equivalent. The parser hands the quoted pattern straight toBetween_Quoteswithout ever looking for a leading!, nopcre_notfield exists anywhere in the rule structure, andPcreS()counts matches with no notion of negation.The
!is therefore dropped silently, andpcre:!"/x/"behaves exactly likepcre:"/x/". A rule written to exclude something requires it instead.What was measured
Sagan built from
3b9b0fa, one reduced rule, one eventuser bob failed loginfrom programsshd:program:sshd;program:sshd; pcre:!"/ZZZ_absent/";program:sshd; pcre:!"/failed/";program:sshd; pcre:"/failed/";Rows two and three are the defect, and they are each other's mirror: the rule stays silent exactly when the forbidden pattern is absent, and fires exactly when it is present.
The same holds with a
contentbeside it, and with two negated patterns, so it is not an interaction with anything else in the rule.Where it comes from
src/rules.c, thepcreblock at line 3013, takes the argument and callswith no
Check_Content_Not(arg)beside it, unlike thecontentblock near line2803 which does exactly that. Nothing else in the block inspects the!.src/pcre-s.c,PcreS()at line 39, is the whole evaluation:Every pattern must match for the rule to pass. There is no per-pattern flag to invert, and
grep -rn pcre_not src/returns nothing.json_pcreis worth checking at the same time: it has its own evaluation path and this issue does not cover it.Suggested directions
Either add the negation, mirroring
content: apcre_not[]flag set byCheck_Content_Notin the parser, andPcreS()counting a non-match as a match for those entries. Or reject the syntax at load time, so a rule that cannot mean what it says is refused rather than quietly inverted.The second is a smaller change and arguably the safer one: silently inverting a security rule's exclusion is worse than refusing to start.
Corpus impact
One rule uses it:
windows-powershell.rulessid 5007143, "Suspicious FromBase64String Encoded Commands". It carriesmeaning to exclude a legitimate Azure DSC module path. As the engine reads it, the rule fires only on that path: it alerts on precisely the activity it was written to ignore, and on nothing else.
No rules change is proposed here. The rule is correct as written; the engine is not.