use std::{fmt, sync::Arc};
use regex::Regex;
use crate::{
remediation::Remediation, rule_metadata::RuleMetadata, severity::Severity,
validators::dispatch::ValidatorKind,
};
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RuleId(Arc<str>);
impl RuleId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for RuleId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl From<&str> for RuleId {
fn from(value: &str) -> Self {
Self(Arc::from(value))
}
}
impl From<String> for RuleId {
fn from(value: String) -> Self {
Self(Arc::from(value))
}
}
impl From<Box<str>> for RuleId {
fn from(value: Box<str>) -> Self {
Self(Arc::from(value))
}
}
impl From<Arc<str>> for RuleId {
fn from(value: Arc<str>) -> Self {
Self(value)
}
}
impl fmt::Display for RuleId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum RuleKind {
Literal,
Prefix,
Suffix,
Pattern,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct RuleSpec {
id: &'static str,
kind: RuleKind,
value: &'static str,
severity: Severity,
validator: ValidatorKind,
remediation: Option<Remediation>,
capture: Option<&'static str>,
}
impl RuleSpec {
#[must_use]
pub const fn literal(id: &'static str, literal: &'static str, severity: Severity) -> Self {
Self {
id,
kind: RuleKind::Literal,
value: literal,
severity,
validator: ValidatorKind::None,
remediation: None,
capture: None,
}
}
#[must_use]
pub const fn prefix(id: &'static str, prefix: &'static str, severity: Severity) -> Self {
Self {
id,
kind: RuleKind::Prefix,
value: prefix,
severity,
validator: ValidatorKind::None,
remediation: None,
capture: None,
}
}
#[must_use]
pub const fn suffix(id: &'static str, suffix: &'static str, severity: Severity) -> Self {
Self {
id,
kind: RuleKind::Suffix,
value: suffix,
severity,
validator: ValidatorKind::None,
remediation: None,
capture: None,
}
}
#[must_use]
pub const fn pattern(id: &'static str, pattern: &'static str, severity: Severity) -> Self {
Self {
id,
kind: RuleKind::Pattern,
value: pattern,
severity,
validator: ValidatorKind::None,
remediation: None,
capture: None,
}
}
pub(crate) const fn captured_pattern(
id: &'static str,
pattern: &'static str,
capture: &'static str,
severity: Severity,
) -> Self {
Self {
id,
kind: RuleKind::Pattern,
value: pattern,
severity,
validator: ValidatorKind::None,
remediation: None,
capture: Some(capture),
}
}
#[must_use]
pub const fn id(self) -> &'static str {
self.id
}
#[must_use]
pub const fn kind(self) -> RuleKind {
self.kind
}
#[must_use]
pub const fn value(self) -> &'static str {
self.value
}
#[must_use]
pub const fn severity(self) -> Severity {
self.severity
}
#[must_use]
pub const fn remediation(self) -> Option<Remediation> {
self.remediation
}
#[must_use]
pub const fn with_remediation(mut self, remediation: Remediation) -> Self {
self.remediation = Some(remediation);
self
}
pub(crate) const fn with_validator(mut self, validator: ValidatorKind) -> Self {
self.validator = validator;
self
}
pub fn to_rule(self) -> Result<Rule, RuleError> {
let rule = match (self.kind, self.capture) {
(RuleKind::Literal, None) => Rule::literal(self.id, self.value, self.severity),
(RuleKind::Prefix, None) => Rule::prefix(self.id, self.value, self.severity),
(RuleKind::Suffix, None) => Rule::suffix(self.id, self.value, self.severity),
(RuleKind::Pattern, None) => Rule::pattern(self.id, self.value, self.severity)?,
(RuleKind::Pattern, Some(capture)) => {
Rule::captured_pattern(self.id, self.value, capture, self.severity)?
}
(_, Some(_)) => unreachable!("only pattern specifications support captures"),
};
let rule = if let Some(remediation) = self.remediation {
rule.with_remediation(remediation)
} else {
rule
};
Ok(rule.with_validator(self.validator))
}
}
#[derive(Debug, Clone)]
pub(crate) enum Matcher {
Literal(Box<str>),
Prefix(Box<str>),
Suffix(Box<str>),
Pattern {
regex: Regex,
capture: Option<usize>,
},
}
#[derive(Debug, Clone)]
pub struct Rule {
pub(crate) id: RuleId,
pub(crate) severity: Severity,
pub(crate) validator: ValidatorKind,
pub(crate) matcher: Matcher,
pub(crate) remediation: Option<Remediation>,
}
impl Rule {
#[must_use]
pub fn literal(
id: impl Into<RuleId>,
literal: impl Into<Box<str>>,
severity: Severity,
) -> Self {
Self {
id: id.into(),
severity,
validator: ValidatorKind::None,
matcher: Matcher::Literal(literal.into()),
remediation: None,
}
}
#[must_use]
pub fn prefix(id: impl Into<RuleId>, prefix: impl Into<Box<str>>, severity: Severity) -> Self {
Self {
id: id.into(),
severity,
validator: ValidatorKind::None,
matcher: Matcher::Prefix(prefix.into()),
remediation: None,
}
}
#[must_use]
pub fn suffix(id: impl Into<RuleId>, suffix: impl Into<Box<str>>, severity: Severity) -> Self {
Self {
id: id.into(),
severity,
validator: ValidatorKind::None,
matcher: Matcher::Suffix(suffix.into()),
remediation: None,
}
}
pub fn pattern(
id: impl Into<RuleId>,
pattern: impl AsRef<str>,
severity: Severity,
) -> Result<Self, RuleError> {
let pattern = pattern.as_ref();
let regex = Regex::new(pattern).map_err(RuleError::InvalidPattern)?;
let hir = regex_syntax::parse(pattern)
.map_err(|error| RuleError::InvalidPattern(regex::Error::Syntax(error.to_string())))?;
if hir.properties().minimum_len() == Some(0) {
return Err(RuleError::PatternMatchesEmpty);
}
Ok(Self {
id: id.into(),
severity,
validator: ValidatorKind::None,
remediation: None,
matcher: Matcher::Pattern {
regex,
capture: None,
},
})
}
pub(crate) fn captured_pattern(
id: impl Into<RuleId>,
pattern: impl AsRef<str>,
capture: impl AsRef<str>,
severity: Severity,
) -> Result<Self, RuleError> {
let regex = Regex::new(pattern.as_ref()).map_err(RuleError::InvalidPattern)?;
let capture_name = capture.as_ref();
let capture_index = regex
.capture_names()
.position(|name| name == Some(capture_name))
.ok_or_else(|| RuleError::MissingCaptureGroup {
name: capture_name.into(),
})?;
Ok(Self {
id: id.into(),
severity,
validator: ValidatorKind::None,
remediation: None,
matcher: Matcher::Pattern {
regex,
capture: Some(capture_index),
},
})
}
pub(crate) const fn with_validator(mut self, validator: ValidatorKind) -> Self {
self.validator = validator;
self
}
#[must_use]
pub fn id(&self) -> &RuleId {
&self.id
}
#[must_use]
pub const fn severity(&self) -> Severity {
self.severity
}
#[must_use]
pub const fn remediation(&self) -> Option<Remediation> {
self.remediation
}
#[must_use]
pub const fn kind(&self) -> RuleKind {
match self.matcher {
Matcher::Literal(_) => RuleKind::Literal,
Matcher::Prefix(_) => RuleKind::Prefix,
Matcher::Suffix(_) => RuleKind::Suffix,
Matcher::Pattern { .. } => RuleKind::Pattern,
}
}
#[must_use]
pub fn with_remediation(mut self, remediation: Remediation) -> Self {
self.remediation = Some(remediation);
self
}
#[must_use]
pub fn metadata(&self) -> RuleMetadata<'_> {
RuleMetadata::new(
self.id.as_str(),
self.kind(),
self.validator.detection_mode(),
self.severity,
self.remediation,
)
}
}
impl fmt::Display for Rule {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.id.fmt(formatter)
}
}
#[derive(Debug)]
pub enum RuleError {
InvalidPattern(regex::Error),
PatternMatchesEmpty,
MissingCaptureGroup {
name: Box<str>,
},
}
impl fmt::Display for RuleError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPattern(error) => write!(formatter, "invalid rule pattern: {error}"),
Self::PatternMatchesEmpty => {
formatter.write_str("rule pattern can produce a zero-length match")
}
Self::MissingCaptureGroup { name } => {
write!(formatter, "missing named capture group `{name}`")
}
}
}
}
impl std::error::Error for RuleError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidPattern(error) => Some(error),
Self::PatternMatchesEmpty | Self::MissingCaptureGroup { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_id_supports_owned_and_borrowed_strings() {
let borrowed = RuleId::from("borrowed");
let owned = RuleId::from(String::from("owned"));
assert_eq!(borrowed.as_str(), "borrowed");
assert_eq!(owned.as_str(), "owned");
}
#[test]
fn static_literal_spec_converts_to_owned_rule() {
let rule = RuleSpec::literal("literal", "SECRET", Severity::High)
.to_rule()
.expect("literal specification should convert");
assert_eq!(rule.id().as_str(), "literal");
assert_eq!(rule.severity(), Severity::High);
}
#[test]
fn static_spec_preserves_internal_validator() {
let rule = RuleSpec::prefix("github", "ghp_", Severity::Critical)
.with_validator(ValidatorKind::GitHub)
.to_rule()
.expect("prefix specification should convert");
assert_eq!(rule.validator, ValidatorKind::GitHub);
}
#[test]
fn metadata_exposes_validator_detection_mode() {
let deterministic = RuleSpec::prefix("github", "ghp_", Severity::Critical)
.with_validator(ValidatorKind::GitHub)
.to_rule()
.expect("prefix specification should convert");
let contextual =
RuleSpec::pattern("password", r#"(?i)password\s*=\s*[^\s]+"#, Severity::High)
.with_validator(ValidatorKind::Password)
.to_rule()
.expect("pattern specification should convert");
assert_eq!(
deterministic.metadata().detection_mode(),
crate::DetectionMode::Deterministic
);
assert_eq!(
contextual.metadata().detection_mode(),
crate::DetectionMode::Contextual
);
}
#[test]
fn captured_pattern_resolves_named_group_once() {
let rule = RuleSpec::captured_pattern(
"assignment",
r#"KEY=(?P<value>[A-Za-z0-9_]+)"#,
"value",
Severity::High,
)
.to_rule()
.expect("named capture should resolve");
assert!(matches!(
rule.matcher,
Matcher::Pattern {
capture: Some(1),
..
}
));
}
#[test]
fn captured_pattern_rejects_missing_named_group() {
let error = RuleSpec::captured_pattern(
"assignment",
r#"KEY=([A-Za-z0-9_]+)"#,
"value",
Severity::High,
)
.to_rule()
.expect_err("missing capture should fail");
assert!(matches!(error, RuleError::MissingCaptureGroup { .. }));
}
#[test]
fn invalid_pattern_is_rejected_during_rule_construction() {
let error =
Rule::pattern("invalid", "(", Severity::High).expect_err("invalid regex should fail");
assert!(matches!(error, RuleError::InvalidPattern(_)));
}
#[test]
fn public_pattern_rejects_zero_length_language() {
for pattern in [r"", r".*", r"a?", r"(?:secret)?", r"\b", r"secret|"] {
let error = Rule::pattern("empty-capable", pattern, Severity::High)
.expect_err("zero-length-capable pattern should fail");
assert!(matches!(error, RuleError::PatternMatchesEmpty), "{pattern}");
}
}
#[test]
fn public_pattern_accepts_non_empty_unicode_and_anchored_patterns() {
for pattern in [
r"\p{L}+",
r"\A[A-Z2-9]{4}(?:-[A-Z2-9]{4}){3}\z",
r"\bsecret\b",
r"(?:foo|bar)+",
] {
let rule = Rule::pattern("non-empty", pattern, Severity::High)
.expect("non-empty pattern should compile");
assert_eq!(rule.kind(), RuleKind::Pattern);
assert_eq!(
rule.metadata().detection_mode(),
crate::DetectionMode::MatcherOnly
);
}
}
}