Skip to main content

synapse_pingora/
rules.rs

1//! Rule storage and conversion helpers for synapse-pingora.
2//!
3//! Supports both native WAF rule JSON and the Signal Horizon custom rule format.
4
5use crate::waf::{MatchCondition, MatchValue, WafRule};
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct RuleMetadata {
12    pub external_id: Option<String>,
13    pub name: Option<String>,
14    #[serde(rename = "type")]
15    pub rule_type: Option<String>,
16    pub enabled: Option<bool>,
17    pub priority: Option<u32>,
18    pub conditions: Option<Vec<CustomRuleCondition>>,
19    pub actions: Option<Vec<CustomRuleAction>>,
20    pub ttl: Option<u64>,
21    pub created_at: Option<String>,
22    pub updated_at: Option<String>,
23    pub hit_count: Option<u64>,
24    pub last_hit: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct StoredRule {
29    #[serde(flatten)]
30    pub rule: WafRule,
31    #[serde(default)]
32    pub meta: RuleMetadata,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CustomRuleCondition {
37    pub field: String,
38    pub operator: String,
39    #[serde(default)]
40    pub value: serde_json::Value,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CustomRuleAction {
45    #[serde(rename = "type")]
46    pub action_type: String,
47    #[serde(default)]
48    pub params: Option<serde_json::Value>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CustomRuleInput {
53    pub id: String,
54    pub name: String,
55    #[serde(rename = "type")]
56    pub rule_type: String,
57    #[serde(default = "default_enabled")]
58    pub enabled: bool,
59    #[serde(default = "default_priority")]
60    pub priority: u32,
61    #[serde(default)]
62    pub conditions: Vec<CustomRuleCondition>,
63    #[serde(default)]
64    pub actions: Vec<CustomRuleAction>,
65    #[serde(default)]
66    pub ttl: Option<u64>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct CustomRuleUpdate {
71    pub name: Option<String>,
72    #[serde(rename = "type")]
73    pub rule_type: Option<String>,
74    pub enabled: Option<bool>,
75    pub priority: Option<u32>,
76    pub conditions: Option<Vec<CustomRuleCondition>>,
77    pub actions: Option<Vec<CustomRuleAction>>,
78    pub ttl: Option<u64>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct RuleView {
83    pub id: String,
84    pub name: String,
85    #[serde(rename = "type")]
86    pub rule_type: String,
87    pub enabled: bool,
88    pub priority: u32,
89    pub conditions: Vec<CustomRuleCondition>,
90    pub actions: Vec<CustomRuleAction>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub ttl: Option<u64>,
93    #[serde(rename = "hitCount")]
94    pub hit_count: u64,
95    #[serde(rename = "lastHit", skip_serializing_if = "Option::is_none")]
96    pub last_hit: Option<String>,
97    #[serde(rename = "createdAt")]
98    pub created_at: String,
99    #[serde(rename = "updatedAt")]
100    pub updated_at: String,
101}
102
103fn default_enabled() -> bool {
104    true
105}
106
107fn default_priority() -> u32 {
108    100
109}
110
111fn now_rfc3339() -> String {
112    Utc::now().to_rfc3339()
113}
114
115fn derive_rule_id(external_id: &str) -> u32 {
116    let mut hasher = Sha256::new();
117    hasher.update(external_id.as_bytes());
118    let digest = hasher.finalize();
119    let value = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
120    if value == 0 {
121        1
122    } else {
123        value
124    }
125}
126
127pub fn rule_identifier(rule: &StoredRule) -> String {
128    rule.meta
129        .external_id
130        .clone()
131        .unwrap_or_else(|| rule.rule.id.to_string())
132}
133
134pub fn matches_rule_id(rule: &StoredRule, rule_id: &str) -> bool {
135    if let Some(external) = rule.meta.external_id.as_deref() {
136        return external == rule_id;
137    }
138    rule.rule.id.to_string() == rule_id
139}
140
141fn normalize_rule_type(rule_type: &str) -> String {
142    rule_type.trim().to_ascii_uppercase()
143}
144
145fn default_rule_type(rule: &WafRule) -> String {
146    if rule.blocking.unwrap_or(false) {
147        "BLOCK".to_string()
148    } else {
149        "MONITOR".to_string()
150    }
151}
152
153fn default_actions(rule: &WafRule) -> Vec<CustomRuleAction> {
154    vec![CustomRuleAction {
155        action_type: if rule.blocking.unwrap_or(false) {
156            "block".to_string()
157        } else {
158            "log".to_string()
159        },
160        params: None,
161    }]
162}
163
164fn apply_meta_overrides(meta: &mut RuleMetadata, value: &serde_json::Value) {
165    if let Some(created_at) = value.get("createdAt").and_then(|v| v.as_str()) {
166        meta.created_at = Some(created_at.to_string());
167    } else if let Some(created_at) = value.get("created_at").and_then(|v| v.as_str()) {
168        meta.created_at = Some(created_at.to_string());
169    }
170
171    if let Some(updated_at) = value.get("updatedAt").and_then(|v| v.as_str()) {
172        meta.updated_at = Some(updated_at.to_string());
173    } else if let Some(updated_at) = value.get("updated_at").and_then(|v| v.as_str()) {
174        meta.updated_at = Some(updated_at.to_string());
175    }
176
177    if let Some(hit_count) = value.get("hitCount").and_then(|v| v.as_u64()) {
178        meta.hit_count = Some(hit_count);
179    } else if let Some(hit_count) = value.get("hit_count").and_then(|v| v.as_u64()) {
180        meta.hit_count = Some(hit_count);
181    }
182
183    if let Some(last_hit) = value.get("lastHit").and_then(|v| v.as_str()) {
184        meta.last_hit = Some(last_hit.to_string());
185    } else if let Some(last_hit) = value.get("last_hit").and_then(|v| v.as_str()) {
186        meta.last_hit = Some(last_hit.to_string());
187    }
188}
189
190fn risk_for_type(rule_type: &str, actions: &[CustomRuleAction]) -> f64 {
191    if actions
192        .iter()
193        .any(|a| a.action_type.eq_ignore_ascii_case("block"))
194    {
195        return 95.0;
196    }
197    if rule_type.eq_ignore_ascii_case("block") {
198        return 90.0;
199    }
200    if rule_type.eq_ignore_ascii_case("challenge") {
201        return 70.0;
202    }
203    if rule_type.eq_ignore_ascii_case("rate_limit") {
204        return 60.0;
205    }
206    15.0
207}
208
209fn blocking_for_rule(rule_type: &str, actions: &[CustomRuleAction]) -> bool {
210    if actions
211        .iter()
212        .any(|a| a.action_type.eq_ignore_ascii_case("block"))
213    {
214        return true;
215    }
216    rule_type.eq_ignore_ascii_case("block")
217}
218
219fn scalar_to_string(value: &serde_json::Value) -> Option<String> {
220    match value {
221        serde_json::Value::String(s) => Some(s.clone()),
222        serde_json::Value::Number(n) => Some(n.to_string()),
223        serde_json::Value::Bool(b) => Some(b.to_string()),
224        _ => None,
225    }
226}
227
228fn value_to_match_value(value: &serde_json::Value) -> Option<MatchValue> {
229    match value {
230        serde_json::Value::String(s) => Some(MatchValue::Str(s.clone())),
231        serde_json::Value::Number(n) => n.as_f64().map(MatchValue::Num),
232        serde_json::Value::Bool(b) => Some(MatchValue::Bool(*b)),
233        serde_json::Value::Array(arr) => {
234            let mut converted = Vec::new();
235            for item in arr {
236                if let Some(v) = value_to_match_value(item) {
237                    converted.push(v);
238                }
239            }
240            Some(MatchValue::Arr(converted))
241        }
242        _ => None,
243    }
244}
245
246fn base_match_condition(kind: &str, match_value: Option<MatchValue>) -> MatchCondition {
247    MatchCondition {
248        kind: kind.to_string(),
249        match_value,
250        op: None,
251        field: None,
252        direction: None,
253        field_type: None,
254        name: None,
255        selector: None,
256        cleanup_after: None,
257        count: None,
258        timeframe: None,
259    }
260}
261
262fn negate_condition(condition: MatchCondition) -> MatchCondition {
263    MatchCondition {
264        kind: "boolean".to_string(),
265        match_value: Some(MatchValue::Arr(vec![MatchValue::Cond(Box::new(condition))])),
266        op: Some("not".to_string()),
267        field: None,
268        direction: None,
269        field_type: None,
270        name: None,
271        selector: None,
272        cleanup_after: None,
273        count: None,
274        timeframe: None,
275    }
276}
277
278fn operator_condition(operator: &str, value: &serde_json::Value) -> Result<MatchCondition, String> {
279    match operator {
280        "eq" => {
281            let match_value = scalar_to_string(value)
282                .map(MatchValue::Str)
283                .ok_or_else(|| "eq operator requires scalar value".to_string())?;
284            Ok(base_match_condition("equals", Some(match_value)))
285        }
286        "contains" => {
287            let match_value = scalar_to_string(value)
288                .map(MatchValue::Str)
289                .ok_or_else(|| "contains operator requires scalar value".to_string())?;
290            Ok(base_match_condition("contains", Some(match_value)))
291        }
292        "matches" => {
293            let match_value = scalar_to_string(value)
294                .map(MatchValue::Str)
295                .ok_or_else(|| "matches operator requires scalar value".to_string())?;
296            Ok(base_match_condition("regex", Some(match_value)))
297        }
298        "gt" | "lt" => {
299            let number = match value {
300                serde_json::Value::Number(n) => n.as_f64(),
301                serde_json::Value::String(s) => s.parse::<f64>().ok(),
302                _ => None,
303            }
304            .ok_or_else(|| "compare operator requires numeric value".to_string())?;
305            let mut cond = base_match_condition("compare", Some(MatchValue::Num(number)));
306            cond.op = Some(operator.to_string());
307            Ok(cond)
308        }
309        "in" => {
310            let arr = match value {
311                serde_json::Value::Array(items) => items,
312                _ => return Err("in operator requires array value".to_string()),
313            };
314            let mut converted = Vec::new();
315            for item in arr {
316                if let Some(value) = scalar_to_string(item) {
317                    converted.push(MatchValue::Str(value));
318                }
319            }
320            if converted.is_empty() {
321                return Err("in operator requires non-empty array".to_string());
322            }
323            Ok(base_match_condition(
324                "hashset",
325                Some(MatchValue::Arr(converted)),
326            ))
327        }
328        "ne" => Err("ne operator not supported for WAF rules".to_string()),
329        other => Err(format!("Unsupported operator: {}", other)),
330    }
331}
332
333fn field_condition(
334    field: &str,
335    operator: &str,
336    value: &serde_json::Value,
337) -> Result<MatchCondition, String> {
338    if operator == "ne" {
339        let condition = field_condition(field, "eq", value)?;
340        return Ok(negate_condition(condition));
341    }
342
343    let field_lower = field.to_lowercase();
344    let op_condition = operator_condition(operator, value)?;
345
346    if field_lower == "method" {
347        if operator == "eq" {
348            if let Some(method) = scalar_to_string(value) {
349                return Ok(base_match_condition(
350                    "method",
351                    Some(MatchValue::Str(method)),
352                ));
353            }
354        }
355        if operator == "in" {
356            if let serde_json::Value::Array(items) = value {
357                let mut methods = Vec::new();
358                for item in items {
359                    if let Some(method) = scalar_to_string(item) {
360                        methods.push(MatchValue::Str(method));
361                    }
362                }
363                if !methods.is_empty() {
364                    return Ok(base_match_condition(
365                        "method",
366                        Some(MatchValue::Arr(methods)),
367                    ));
368                }
369            }
370        }
371        return Ok(base_match_condition(
372            "method",
373            Some(MatchValue::Cond(Box::new(op_condition))),
374        ));
375    }
376
377    if matches!(field_lower.as_str(), "uri" | "path" | "url") {
378        return Ok(base_match_condition(
379            "uri",
380            Some(MatchValue::Cond(Box::new(op_condition))),
381        ));
382    }
383
384    if field_lower == "args" || field_lower == "query" {
385        return Ok(base_match_condition(
386            "args",
387            Some(MatchValue::Cond(Box::new(op_condition))),
388        ));
389    }
390
391    if let Some(name) = field_lower.strip_prefix("arg.") {
392        let mut cond = base_match_condition(
393            "named_argument",
394            Some(MatchValue::Cond(Box::new(op_condition))),
395        );
396        cond.name = Some(name.to_string());
397        return Ok(cond);
398    }
399
400    if let Some(name) = field_lower.strip_prefix("param.") {
401        let mut cond = base_match_condition(
402            "named_argument",
403            Some(MatchValue::Cond(Box::new(op_condition))),
404        );
405        cond.name = Some(name.to_string());
406        return Ok(cond);
407    }
408
409    if let Some(name) = field_lower.strip_prefix("header.") {
410        let mut cond =
411            base_match_condition("header", Some(MatchValue::Cond(Box::new(op_condition))));
412        cond.field = Some(name.to_string());
413        cond.direction = Some("c2s".to_string());
414        return Ok(cond);
415    }
416
417    if let Some(name) = field_lower.strip_prefix("header:") {
418        let mut cond =
419            base_match_condition("header", Some(MatchValue::Cond(Box::new(op_condition))));
420        cond.field = Some(name.to_string());
421        cond.direction = Some("c2s".to_string());
422        return Ok(cond);
423    }
424
425    if field_lower == "body" || field_lower == "request" {
426        return Ok(base_match_condition(
427            "request",
428            Some(MatchValue::Cond(Box::new(op_condition))),
429        ));
430    }
431
432    let mut cond = base_match_condition("header", Some(MatchValue::Cond(Box::new(op_condition))));
433    cond.field = Some(field.to_string());
434    cond.direction = Some("c2s".to_string());
435    Ok(cond)
436}
437
438fn conditions_to_matches(
439    conditions: &[CustomRuleCondition],
440) -> Result<Vec<MatchCondition>, String> {
441    let mut matches = Vec::new();
442    for condition in conditions {
443        matches.push(field_condition(
444            condition.field.as_str(),
445            condition.operator.as_str(),
446            &condition.value,
447        )?);
448    }
449    if matches.is_empty() {
450        return Err("custom rule must include at least one condition".to_string());
451    }
452    Ok(matches)
453}
454
455impl StoredRule {
456    pub fn from_custom(custom: CustomRuleInput) -> Result<Self, String> {
457        let matches = conditions_to_matches(&custom.conditions)?;
458        let rule_id = derive_rule_id(&custom.id);
459        let risk = risk_for_type(&custom.rule_type, &custom.actions);
460        let blocking = blocking_for_rule(&custom.rule_type, &custom.actions);
461
462        let rule = WafRule {
463            id: rule_id,
464            description: custom.name.clone(),
465            contributing_score: None,
466            risk: Some(risk),
467            blocking: Some(blocking),
468            matches,
469        };
470
471        let now = now_rfc3339();
472        let meta = RuleMetadata {
473            external_id: Some(custom.id),
474            name: Some(custom.name),
475            rule_type: Some(custom.rule_type),
476            enabled: Some(custom.enabled),
477            priority: Some(custom.priority),
478            conditions: Some(custom.conditions),
479            actions: Some(custom.actions),
480            ttl: custom.ttl,
481            created_at: Some(now.clone()),
482            updated_at: Some(now),
483            hit_count: Some(0),
484            last_hit: None,
485        };
486
487        Ok(Self { rule, meta })
488    }
489}
490
491impl RuleView {
492    pub fn from_stored(rule: &StoredRule) -> Self {
493        let meta = &rule.meta;
494        let id = rule_identifier(rule);
495        let name = meta
496            .name
497            .clone()
498            .unwrap_or_else(|| rule.rule.description.clone());
499        let rule_type = meta
500            .rule_type
501            .as_deref()
502            .map(normalize_rule_type)
503            .unwrap_or_else(|| default_rule_type(&rule.rule));
504        let enabled = meta.enabled.unwrap_or(true);
505        let priority = meta.priority.unwrap_or(100);
506        let conditions = meta.conditions.clone().unwrap_or_default();
507        let actions = meta
508            .actions
509            .clone()
510            .filter(|items| !items.is_empty())
511            .unwrap_or_else(|| default_actions(&rule.rule));
512        let ttl = meta.ttl;
513        let hit_count = meta.hit_count.unwrap_or(0);
514        let last_hit = meta.last_hit.clone();
515        let created_at = meta.created_at.clone().unwrap_or_else(now_rfc3339);
516        let updated_at = meta
517            .updated_at
518            .clone()
519            .unwrap_or_else(|| created_at.clone());
520
521        Self {
522            id,
523            name,
524            rule_type,
525            enabled,
526            priority,
527            conditions,
528            actions,
529            ttl,
530            hit_count,
531            last_hit,
532            created_at,
533            updated_at,
534        }
535    }
536}
537
538pub fn parse_rule_value(value: serde_json::Value) -> Result<StoredRule, String> {
539    if value.get("matches").is_some() {
540        let rule: WafRule = serde_json::from_value(value.clone())
541            .map_err(|err| format!("invalid waf rule: {}", err))?;
542        let mut meta = RuleMetadata::default();
543        meta.external_id = Some(rule.id.to_string());
544        meta.name = Some(rule.description.clone());
545        meta.rule_type = Some(default_rule_type(&rule));
546        meta.enabled = Some(true);
547        meta.priority = Some(100);
548        meta.actions = Some(default_actions(&rule));
549        meta.created_at = Some(now_rfc3339());
550        meta.updated_at = Some(now_rfc3339());
551        apply_meta_overrides(&mut meta, &value);
552        Ok(StoredRule { rule, meta })
553    } else {
554        let custom: CustomRuleInput = serde_json::from_value(value.clone())
555            .map_err(|err| format!("invalid custom rule: {}", err))?;
556        let mut stored = StoredRule::from_custom(custom)?;
557        apply_meta_overrides(&mut stored.meta, &value);
558        Ok(stored)
559    }
560}
561
562pub fn parse_rules_payload(value: serde_json::Value) -> Result<Vec<StoredRule>, String> {
563    let serde_json::Value::Array(items) = value else {
564        return Err("rules payload must be an array".to_string());
565    };
566
567    let mut rules = Vec::with_capacity(items.len());
568    for item in items {
569        rules.push(parse_rule_value(item)?);
570    }
571    Ok(rules)
572}
573
574pub fn merge_rule_update(
575    existing: &StoredRule,
576    update: CustomRuleUpdate,
577) -> Result<StoredRule, String> {
578    let mut meta = existing.meta.clone();
579    if meta.created_at.is_none() {
580        meta.created_at = Some(now_rfc3339());
581    }
582    meta.updated_at = Some(now_rfc3339());
583
584    if let Some(name) = update.name {
585        meta.name = Some(name);
586    }
587    if let Some(rule_type) = update.rule_type {
588        meta.rule_type = Some(rule_type);
589    }
590    if let Some(enabled) = update.enabled {
591        meta.enabled = Some(enabled);
592    }
593    if let Some(priority) = update.priority {
594        meta.priority = Some(priority);
595    }
596    if let Some(conditions) = update.conditions {
597        meta.conditions = Some(conditions);
598    }
599    if let Some(actions) = update.actions {
600        meta.actions = Some(actions);
601    }
602    if let Some(ttl) = update.ttl {
603        meta.ttl = Some(ttl);
604    }
605
606    let has_conditions = meta
607        .conditions
608        .as_ref()
609        .map(|items| !items.is_empty())
610        .unwrap_or(false);
611
612    if has_conditions {
613        let external_id = meta
614            .external_id
615            .clone()
616            .unwrap_or_else(|| existing.rule.id.to_string());
617        let name = meta
618            .name
619            .clone()
620            .unwrap_or_else(|| existing.rule.description.clone());
621        let rule_type = meta
622            .rule_type
623            .clone()
624            .unwrap_or_else(|| default_rule_type(&existing.rule));
625        let enabled = meta.enabled.unwrap_or(true);
626        let priority = meta.priority.unwrap_or(100);
627        let conditions = meta.conditions.clone().unwrap_or_default();
628        let actions = meta
629            .actions
630            .clone()
631            .filter(|items| !items.is_empty())
632            .unwrap_or_else(|| default_actions(&existing.rule));
633
634        let custom = CustomRuleInput {
635            id: external_id,
636            name,
637            rule_type,
638            enabled,
639            priority,
640            conditions,
641            actions,
642            ttl: meta.ttl,
643        };
644
645        let mut stored = StoredRule::from_custom(custom)?;
646        stored.meta.created_at = meta.created_at.clone();
647        stored.meta.updated_at = meta.updated_at.clone();
648        stored.meta.hit_count = meta.hit_count;
649        stored.meta.last_hit = meta.last_hit.clone();
650        return Ok(stored);
651    }
652
653    let rule_type = meta
654        .rule_type
655        .clone()
656        .unwrap_or_else(|| default_rule_type(&existing.rule));
657    let actions = meta
658        .actions
659        .clone()
660        .filter(|items| !items.is_empty())
661        .unwrap_or_else(|| default_actions(&existing.rule));
662
663    let mut stored = existing.clone();
664    stored.meta = meta.clone();
665    stored.rule.description = meta
666        .name
667        .clone()
668        .unwrap_or_else(|| existing.rule.description.clone());
669    stored.rule.risk = Some(risk_for_type(&rule_type, &actions));
670    stored.rule.blocking = Some(blocking_for_rule(&rule_type, &actions));
671
672    Ok(stored)
673}
674
675pub fn rules_hash(rules: &[StoredRule]) -> String {
676    let mut views: Vec<RuleView> = rules.iter().map(RuleView::from_stored).collect();
677    views.sort_by(|a, b| a.id.cmp(&b.id));
678
679    let payload = serde_json::to_string(&views).unwrap_or_default();
680    let mut hasher = Sha256::new();
681    hasher.update(payload.as_bytes());
682    format!("{:x}", hasher.finalize())
683}