1use std::collections::{HashMap, HashSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::session_warnings::SessionWarnings;
15use crate::verdict::{Action, Finding, Severity, Verdict};
16
17fn default_window_60() -> u64 {
18 60
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "trigger", rename_all = "snake_case")]
25pub enum EscalationRule {
26 RepeatCount {
29 rule_ids: Vec<String>,
30 threshold: u32,
31 #[serde(default = "default_window_60")]
32 window_minutes: u64,
33 action: EscalationAction,
34 #[serde(default)]
35 domain_scoped: bool,
36 #[serde(default)]
39 cooldown_minutes: u64,
40 },
41 MultiMedium {
44 min_findings: u32,
45 action: EscalationAction,
46 },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum EscalationAction {
54 Block,
55}
56
57impl EscalationAction {
58 fn to_action(self) -> Action {
59 match self {
60 EscalationAction::Block => Action::Block,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub struct EscalationHit {
69 pub rule_id: String,
71 pub domain: Option<String>,
73}
74
75impl EscalationHit {
76 pub fn is_wildcard(&self) -> bool {
78 self.rule_id == "*"
79 }
80}
81
82pub fn apply_escalation(
88 current_action: Action,
89 findings: &[Finding],
90 session: &SessionWarnings,
91 rules: &[EscalationRule],
92) -> (Action, HashSet<String>, Vec<EscalationHit>, Option<String>) {
93 let mut action = current_action;
94 let mut causal = HashSet::new();
95 let mut hits: Vec<EscalationHit> = Vec::new();
96 let mut reason: Option<String> = None;
97
98 for rule in rules {
99 match rule {
100 EscalationRule::RepeatCount {
101 rule_ids,
102 threshold,
103 window_minutes,
104 action: esc_action,
105 domain_scoped,
106 cooldown_minutes,
107 } => {
108 let target = esc_action.to_action();
109 if action_gte(action, target) {
110 continue;
111 }
112
113 let wildcard = rule_ids.iter().any(|id| id == "*");
114
115 let current_counts: HashMap<(String, Option<String>), u32> = {
118 let mut map = HashMap::new();
119 for f in findings {
120 let rid = f.rule_id.to_string();
121 if !wildcard && !rule_ids.iter().any(|id| id == &rid) {
122 continue;
123 }
124 if *domain_scoped {
125 let domains = extract_finding_domains(f);
126 if domains.is_empty() {
127 *map.entry((rid, None)).or_insert(0) += 1;
128 } else {
129 for d in domains {
130 *map.entry((rid.clone(), Some(d))).or_insert(0) += 1;
131 }
132 }
133 } else {
134 *map.entry((rid, None)).or_insert(0) += 1;
135 }
136 }
137 map
138 };
139
140 for ((fid, domain), current_count) in ¤t_counts {
141 if action_gte(action, target) {
142 break;
143 }
144
145 if *cooldown_minutes > 0 {
146 let cooldown_active = session.escalation_events.iter().any(|ev| {
147 let rule_matches = if ev.rule_id == "*" {
148 false
150 } else {
151 ev.rule_id == *fid
152 };
153 let domain_matches = match (&ev.domain, domain) {
154 (Some(ed), Some(fd)) => ed == fd,
155 (None, None) => true,
156 _ => false,
157 };
158 rule_matches
159 && domain_matches
160 && is_within_minutes(&ev.timestamp, *cooldown_minutes)
161 });
162 if cooldown_active {
163 continue;
164 }
165 }
166
167 let session_count = if *domain_scoped {
168 match domain {
169 Some(d) => session.count_by_rule_and_domain(fid, d, *window_minutes),
170 None => session.count_by_rule(fid, *window_minutes),
171 }
172 } else {
173 session.count_by_rule(fid, *window_minutes)
174 };
175
176 let total = session_count + current_count;
177 if total >= *threshold {
178 action = target;
179 causal.insert(fid.clone());
180 hits.push(EscalationHit {
181 rule_id: fid.clone(),
182 domain: domain.clone(),
183 });
184 if wildcard {
187 hits.push(EscalationHit {
188 rule_id: "*".to_string(),
189 domain: None,
190 });
191 }
192 if reason.is_none() {
193 reason = Some(format!(
194 "{fid} triggered {total} times in {window_minutes}m (threshold: {threshold})"
195 ));
196 }
197 }
198 }
199
200 if wildcard && !domain_scoped && !action_gte(action, target) {
204 if *cooldown_minutes > 0 {
205 let wildcard_cooled = session.escalation_events.iter().any(|ev| {
206 ev.rule_id == "*" && is_within_minutes(&ev.timestamp, *cooldown_minutes)
207 });
208 if wildcard_cooled {
209 continue;
210 }
211 }
212
213 let total = session.count_all(*window_minutes) + findings.len() as u32;
214 if total >= *threshold {
215 action = target;
216 for f in findings {
217 causal.insert(f.rule_id.to_string());
218 }
219 hits.push(EscalationHit {
220 rule_id: "*".to_string(),
221 domain: None,
222 });
223 if reason.is_none() {
224 reason = Some(format!(
225 "{total} warnings in {window_minutes}m (threshold: {threshold})"
226 ));
227 }
228 }
229 }
230 }
231 EscalationRule::MultiMedium {
232 min_findings,
233 action: esc_action,
234 } => {
235 let target = esc_action.to_action();
236 if action_gte(action, target) {
237 continue;
238 }
239 let med_plus_count = findings
240 .iter()
241 .filter(|f| f.severity >= Severity::Medium)
242 .count() as u32;
243 if med_plus_count >= *min_findings {
244 action = target;
245 for f in findings.iter().filter(|f| f.severity >= Severity::Medium) {
246 causal.insert(f.rule_id.to_string());
247 }
248 if reason.is_none() {
249 reason = Some(format!(
250 "{med_plus_count} medium+ findings on one command (threshold: {min_findings})"
251 ));
252 }
253 }
254 }
255 }
256 }
257
258 let mut seen = HashSet::new();
259 hits.retain(|h| seen.insert((h.rule_id.clone(), h.domain.clone())));
260
261 (action, causal, hits, reason)
262}
263
264fn is_within_minutes(timestamp: &str, minutes: u64) -> bool {
269 let Ok(ts) = chrono::DateTime::parse_from_rfc3339(timestamp) else {
270 return true;
271 };
272 let cutoff =
273 chrono::Utc::now() - chrono::Duration::minutes(minutes.min(u32::MAX as u64) as i64);
274 ts >= cutoff
275}
276
277pub fn apply_action_overrides(
280 current_action: Action,
281 findings: &[Finding],
282 overrides: &HashMap<String, String>,
283) -> (Action, HashSet<String>) {
284 let mut action = current_action;
285 let mut causal = HashSet::new();
286
287 for finding in findings {
288 let fid = finding.rule_id.to_string();
289 if let Some(override_action) = overrides.get(&fid) {
290 if override_action == "block" && !action_gte(action, Action::Block) {
291 action = Action::Block;
292 causal.insert(fid);
293 }
294 }
295 }
296
297 (action, causal)
298}
299
300fn action_gte(a: Action, b: Action) -> bool {
302 action_rank(a) >= action_rank(b)
303}
304
305fn action_rank(a: Action) -> u8 {
306 match a {
307 Action::Allow => 0,
308 Action::Warn => 1,
309 Action::WarnAck => 2,
310 Action::Block => 3,
311 }
312}
313
314fn extract_finding_domains(finding: &Finding) -> Vec<String> {
316 crate::session_warnings::extract_domains_from_evidence(&finding.evidence)
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum CallerContext {
323 Cli,
324 Gateway,
325 McpServer,
326 Daemon,
327}
328
329pub fn post_process_verdict(
335 raw_verdict: &Verdict,
336 policy: &crate::policy::Policy,
337 cmd: &str,
338 session_id: &str,
339 caller: CallerContext,
340) -> Verdict {
341 let mut effective = raw_verdict.clone();
342 let mut causal_rule_ids: HashSet<String> = HashSet::new();
343
344 if !policy.action_overrides.is_empty() {
347 let (new_action, caused_by) = apply_action_overrides(
348 effective.action,
349 &effective.findings,
350 &policy.action_overrides,
351 );
352 effective.action = new_action;
353 causal_rule_ids.extend(caused_by);
354 }
355
356 if let Some(meta) = crate::approval::check_approval(&effective, policy) {
359 crate::approval::apply_approval(&mut effective, &meta);
360 causal_rule_ids.insert(meta.rule_id.clone());
361 if caller != CallerContext::Cli && effective.requires_approval == Some(true) {
362 effective.action = Action::Block;
363 }
364 }
365
366 let pre_paranoia_action = effective.action;
369
370 let causal_indices: Vec<usize> = raw_verdict
371 .findings
372 .iter()
373 .enumerate()
374 .filter(|(_, f)| causal_rule_ids.contains(&f.rule_id.to_string()))
375 .map(|(i, _)| i)
376 .collect();
377
378 crate::engine::filter_findings_by_paranoia(&mut effective, policy.paranoia);
379
380 if !causal_rule_ids.is_empty()
384 && action_rank(pre_paranoia_action) > action_rank(effective.action)
385 {
386 effective.action = pre_paranoia_action;
387 }
388
389 for &idx in &causal_indices {
390 if idx < raw_verdict.findings.len() {
391 let causal = &raw_verdict.findings[idx];
392 let already_present = effective.findings.iter().any(|ef| {
393 ef.rule_id == causal.rule_id
394 && ef.severity == causal.severity
395 && ef.title == causal.title
396 && ef.description == causal.description
397 });
398 if !already_present {
399 effective.findings.push(causal.clone());
400 }
401 }
402 }
403
404 if !causal_rule_ids.is_empty()
409 && effective.action != Action::Allow
410 && effective.findings.is_empty()
411 {
412 for f in &raw_verdict.findings {
413 if f.severity >= Severity::Low
414 && !effective.findings.iter().any(|ef| ef.rule_id == f.rule_id)
415 {
416 effective.findings.push(f.clone());
417 }
418 }
419 }
420
421 if !policy.escalation.is_empty() && matches!(effective.action, Action::Warn | Action::WarnAck) {
423 let session = crate::session_warnings::load(session_id);
424 let (new_action, caused_by, escalation_hits, reason) = apply_escalation(
425 effective.action,
426 &effective.findings,
427 &session,
428 &policy.escalation,
429 );
430 if new_action != effective.action {
431 effective.escalation_reason = reason;
432 crate::session_warnings::record_escalation_event(session_id, &escalation_hits);
435 }
436 effective.action = new_action;
437 causal_rule_ids.extend(caused_by);
438 }
439
440 let hidden_findings_vec: Vec<&Finding> = {
444 let mut effective_counts: HashMap<(String, String, String, String), u32> = HashMap::new();
445 for f in &effective.findings {
446 let key = (
447 f.rule_id.to_string(),
448 f.severity.to_string(),
449 f.title.clone(),
450 f.description.clone(),
451 );
452 *effective_counts.entry(key).or_insert(0) += 1;
453 }
454 let mut hidden = Vec::new();
455 for f in &raw_verdict.findings {
456 let key = (
457 f.rule_id.to_string(),
458 f.severity.to_string(),
459 f.title.clone(),
460 f.description.clone(),
461 );
462 match effective_counts.get_mut(&key) {
463 Some(count) if *count > 0 => {
464 *count -= 1;
465 }
466 _ => {
467 hidden.push(f);
468 }
469 }
470 }
471 hidden
472 };
473
474 if matches!(effective.action, Action::Warn | Action::WarnAck) || !hidden_findings_vec.is_empty()
475 {
476 let warn_findings: Vec<&Finding> =
477 if matches!(effective.action, Action::Warn | Action::WarnAck) {
478 effective
479 .findings
480 .iter()
481 .filter(|f| f.severity >= Severity::Low)
482 .collect()
483 } else {
484 vec![]
485 };
486 crate::session_warnings::record_outcome(
487 session_id,
488 &warn_findings,
489 &hidden_findings_vec,
490 cmd,
491 &policy.dlp_custom_patterns,
492 );
493 }
494
495 effective
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::verdict::{Evidence, Finding, RuleId, Severity, Timings};
502
503 fn make_finding(rule_id: RuleId, severity: Severity) -> Finding {
504 Finding {
505 rule_id,
506 severity,
507 title: format!("{rule_id:?} finding"),
508 description: "test description".to_string(),
509 evidence: vec![Evidence::Text {
510 detail: "test".to_string(),
511 }],
512 human_view: None,
513 agent_view: None,
514 mitre_id: None,
515 custom_rule_id: None,
516 }
517 }
518
519 fn empty_session() -> SessionWarnings {
520 SessionWarnings {
521 session_id: "test".to_string(),
522 session_start: chrono::Utc::now().to_rfc3339(),
523 total_warnings: 0,
524 hidden_findings: 0,
525 hidden_low: 0,
526 hidden_info: 0,
527 events: std::collections::VecDeque::new(),
528 escalation_events: std::collections::VecDeque::new(),
529 hidden_events: std::collections::VecDeque::new(),
530 }
531 }
532
533 fn session_with_history(rule_id: &str, count: u32) -> SessionWarnings {
534 let mut session = empty_session();
535 let now = chrono::Utc::now().to_rfc3339();
536 for _ in 0..count {
537 session
538 .events
539 .push_back(crate::session_warnings::WarningEvent {
540 timestamp: now.clone(),
541 rule_id: rule_id.to_string(),
542 severity: "MEDIUM".to_string(),
543 title: "test".to_string(),
544 command_redacted: "cmd".to_string(),
545 domains: vec![],
546 });
547 }
548 session.total_warnings = count;
549 session
550 }
551
552 #[test]
553 fn test_repeat_count_below_threshold() {
554 let session = session_with_history("non_ascii_hostname", 2);
555 let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
556 let rules = vec![EscalationRule::RepeatCount {
557 rule_ids: vec!["non_ascii_hostname".to_string()],
558 threshold: 5,
559 window_minutes: 60,
560 action: EscalationAction::Block,
561 domain_scoped: false,
562 cooldown_minutes: 0,
563 }];
564
565 let (action, causal, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
566 assert_eq!(action, Action::Warn);
568 assert!(causal.is_empty());
569 }
570
571 #[test]
572 fn test_repeat_count_meets_threshold() {
573 let session = session_with_history("non_ascii_hostname", 4);
574 let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
575 let rules = vec![EscalationRule::RepeatCount {
576 rule_ids: vec!["non_ascii_hostname".to_string()],
577 threshold: 5,
578 window_minutes: 60,
579 action: EscalationAction::Block,
580 domain_scoped: false,
581 cooldown_minutes: 0,
582 }];
583
584 let (action, causal, hits, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
585 assert_eq!(action, Action::Block);
586 assert!(causal.contains("non_ascii_hostname"));
587 assert!(hits
588 .iter()
589 .any(|h| h.rule_id == "non_ascii_hostname" && !h.is_wildcard()));
590 }
591
592 #[test]
593 fn test_repeat_count_wildcard() {
594 let session = session_with_history("any_rule", 9);
595 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
596 let rules = vec![EscalationRule::RepeatCount {
597 rule_ids: vec!["*".to_string()],
598 threshold: 10,
599 window_minutes: 60,
600 action: EscalationAction::Block,
601 domain_scoped: false,
602 cooldown_minutes: 0,
603 }];
604
605 let (action, _, hits, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
606 assert_eq!(action, Action::Block);
608 assert!(hits.iter().any(|h| h.rule_id == "*" && h.is_wildcard()));
609 }
610
611 #[test]
612 fn test_multi_medium_below_threshold() {
613 let session = empty_session();
614 let findings = vec![
615 make_finding(RuleId::NonAsciiHostname, Severity::Medium),
616 make_finding(RuleId::ShortenedUrl, Severity::Low),
617 ];
618 let rules = vec![EscalationRule::MultiMedium {
619 min_findings: 3,
620 action: EscalationAction::Block,
621 }];
622
623 let (action, _, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
624 assert_eq!(action, Action::Warn);
626 }
627
628 #[test]
629 fn test_multi_medium_meets_threshold() {
630 let session = empty_session();
631 let findings = vec![
632 make_finding(RuleId::NonAsciiHostname, Severity::Medium),
633 make_finding(RuleId::ShortenedUrl, Severity::Medium),
634 make_finding(RuleId::PlainHttpToSink, Severity::High),
635 ];
636 let rules = vec![EscalationRule::MultiMedium {
637 min_findings: 3,
638 action: EscalationAction::Block,
639 }];
640
641 let (action, causal, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
642 assert_eq!(action, Action::Block);
643 assert_eq!(causal.len(), 3);
644 }
645
646 #[test]
647 fn test_escalation_never_downgrades() {
648 let session = empty_session();
649 let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
650 let rules = vec![EscalationRule::RepeatCount {
651 rule_ids: vec!["non_ascii_hostname".to_string()],
652 threshold: 999,
653 window_minutes: 60,
654 action: EscalationAction::Block,
655 domain_scoped: false,
656 cooldown_minutes: 0,
657 }];
658
659 let (action, _, _, _) = apply_escalation(Action::Block, &findings, &session, &rules);
661 assert_eq!(action, Action::Block);
662 }
663
664 #[test]
665 fn test_cooldown_suppresses_escalation() {
666 let mut session = session_with_history("shortened_url", 4);
667 session
668 .escalation_events
669 .push_back(crate::session_warnings::EscalationEvent {
670 timestamp: chrono::Utc::now().to_rfc3339(),
671 rule_id: "shortened_url".to_string(),
672 domain: None,
673 });
674 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
675 let rules = vec![EscalationRule::RepeatCount {
676 rule_ids: vec!["shortened_url".to_string()],
677 threshold: 3,
678 window_minutes: 60,
679 action: EscalationAction::Block,
680 domain_scoped: false,
681 cooldown_minutes: 60,
682 }];
683
684 let (action, causal, hits, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
686 assert_eq!(action, Action::Warn);
687 assert!(causal.is_empty());
688 assert!(hits.is_empty());
689 }
690
691 #[test]
692 fn test_cooldown_zero_does_not_suppress() {
693 let mut session = session_with_history("shortened_url", 4);
695 session
696 .escalation_events
697 .push_back(crate::session_warnings::EscalationEvent {
698 timestamp: chrono::Utc::now().to_rfc3339(),
699 rule_id: "shortened_url".to_string(),
700 domain: None,
701 });
702 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
703 let rules = vec![EscalationRule::RepeatCount {
704 rule_ids: vec!["shortened_url".to_string()],
705 threshold: 3,
706 window_minutes: 60,
707 action: EscalationAction::Block,
708 domain_scoped: false,
709 cooldown_minutes: 0,
710 }];
711
712 let (action, _, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
713 assert_eq!(action, Action::Block);
714 }
715
716 #[test]
717 fn test_wildcard_cooldown_suppresses_aggregate() {
718 let mut session = session_with_history("any_rule", 9);
720 session
721 .escalation_events
722 .push_back(crate::session_warnings::EscalationEvent {
723 timestamp: chrono::Utc::now().to_rfc3339(),
724 rule_id: "*".to_string(),
725 domain: None,
726 });
727 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
728 let rules = vec![EscalationRule::RepeatCount {
729 rule_ids: vec!["*".to_string()],
730 threshold: 10,
731 window_minutes: 60,
732 action: EscalationAction::Block,
733 domain_scoped: false,
734 cooldown_minutes: 60,
735 }];
736
737 let (action, _, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
738 assert_eq!(action, Action::Warn);
740 }
741
742 #[test]
743 fn test_action_override_block() {
744 let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
745 let mut overrides = HashMap::new();
746 overrides.insert("non_ascii_hostname".to_string(), "block".to_string());
747
748 let (action, causal) = apply_action_overrides(Action::Warn, &findings, &overrides);
749 assert_eq!(action, Action::Block);
750 assert!(causal.contains("non_ascii_hostname"));
751 }
752
753 #[test]
754 fn test_action_override_invalid_value_ignored() {
755 let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
756 let mut overrides = HashMap::new();
757 overrides.insert("non_ascii_hostname".to_string(), "warn".to_string());
759
760 let (action, causal) = apply_action_overrides(Action::Warn, &findings, &overrides);
761 assert_eq!(action, Action::Warn);
762 assert!(causal.is_empty());
763 }
764
765 #[test]
766 fn test_action_override_no_match() {
767 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
768 let mut overrides = HashMap::new();
769 overrides.insert("non_ascii_hostname".to_string(), "block".to_string());
770
771 let (action, causal) = apply_action_overrides(Action::Warn, &findings, &overrides);
772 assert_eq!(action, Action::Warn);
773 assert!(causal.is_empty());
774 }
775
776 #[test]
777 fn test_post_process_noop_on_allow() {
778 let raw = Verdict {
779 action: Action::Allow,
780 findings: vec![],
781 tier_reached: 1,
782 bypass_requested: false,
783 bypass_honored: false,
784 bypass_available: false,
785 interactive_detected: false,
786 policy_path_used: None,
787 timings_ms: Timings::default(),
788 urls_extracted_count: None,
789 requires_approval: None,
790 approval_timeout_secs: None,
791 approval_fallback: None,
792 approval_rule: None,
793 approval_description: None,
794 escalation_reason: None,
795 };
796 let policy = crate::policy::Policy::default();
797 let result = post_process_verdict(
798 &raw,
799 &policy,
800 "echo hello",
801 "test-session",
802 CallerContext::Cli,
803 );
804 assert_eq!(result.action, Action::Allow);
805 assert!(result.findings.is_empty());
806 }
807
808 #[test]
809 fn test_post_process_action_override_upgrades() {
810 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
811 let raw = Verdict {
812 action: Action::Warn,
813 findings,
814 tier_reached: 3,
815 bypass_requested: false,
816 bypass_honored: false,
817 bypass_available: false,
818 interactive_detected: false,
819 policy_path_used: None,
820 timings_ms: Timings::default(),
821 urls_extracted_count: None,
822 requires_approval: None,
823 approval_timeout_secs: None,
824 approval_fallback: None,
825 approval_rule: None,
826 approval_description: None,
827 escalation_reason: None,
828 };
829
830 let mut policy = crate::policy::Policy::default();
831 policy
832 .action_overrides
833 .insert("shortened_url".to_string(), "block".to_string());
834
835 let result = post_process_verdict(
836 &raw,
837 &policy,
838 "bit.ly/foo",
839 "test-session",
840 CallerContext::Cli,
841 );
842 assert_eq!(result.action, Action::Block);
843 }
844
845 #[test]
846 fn test_post_process_ordering_override_before_escalation() {
847 let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
850 let raw = Verdict {
851 action: Action::Warn,
852 findings,
853 tier_reached: 3,
854 bypass_requested: false,
855 bypass_honored: false,
856 bypass_available: false,
857 interactive_detected: false,
858 policy_path_used: None,
859 timings_ms: Timings::default(),
860 urls_extracted_count: None,
861 requires_approval: None,
862 approval_timeout_secs: None,
863 approval_fallback: None,
864 approval_rule: None,
865 approval_description: None,
866 escalation_reason: None,
867 };
868
869 let mut policy = crate::policy::Policy::default();
870 policy
871 .action_overrides
872 .insert("shortened_url".to_string(), "block".to_string());
873 policy.escalation.push(EscalationRule::RepeatCount {
874 rule_ids: vec!["shortened_url".to_string()],
875 threshold: 999,
876 window_minutes: 60,
877 action: EscalationAction::Block,
878 domain_scoped: false,
879 cooldown_minutes: 0,
880 });
881
882 let result = post_process_verdict(
883 &raw,
884 &policy,
885 "bit.ly/foo",
886 "test-session",
887 CallerContext::Cli,
888 );
889 assert_eq!(result.action, Action::Block);
890 }
891
892 #[test]
893 fn test_escalation_rule_serde() {
894 let json = r#"{"trigger":"repeat_count","rule_ids":["*"],"threshold":5,"action":"block"}"#;
895 let rule: EscalationRule = serde_json::from_str(json).unwrap();
896 match rule {
897 EscalationRule::RepeatCount {
898 threshold,
899 window_minutes,
900 cooldown_minutes,
901 ..
902 } => {
903 assert_eq!(threshold, 5);
904 assert_eq!(window_minutes, 60);
905 assert_eq!(cooldown_minutes, 0);
906 }
907 _ => panic!("expected RepeatCount"),
908 }
909
910 let json_cd = r#"{"trigger":"repeat_count","rule_ids":["*"],"threshold":5,"action":"block","cooldown_minutes":10}"#;
911 let rule_cd: EscalationRule = serde_json::from_str(json_cd).unwrap();
912 match rule_cd {
913 EscalationRule::RepeatCount {
914 cooldown_minutes, ..
915 } => {
916 assert_eq!(cooldown_minutes, 10);
917 }
918 _ => panic!("expected RepeatCount"),
919 }
920
921 let json2 = r#"{"trigger":"multi_medium","min_findings":3,"action":"block"}"#;
922 let rule2: EscalationRule = serde_json::from_str(json2).unwrap();
923 match rule2 {
924 EscalationRule::MultiMedium { min_findings, .. } => {
925 assert_eq!(min_findings, 3);
926 }
927 _ => panic!("expected MultiMedium"),
928 }
929 }
930
931 #[test]
932 fn test_hidden_count_multiset_with_duplicates() {
933 let dup_finding = make_finding(RuleId::ShortenedUrl, Severity::Medium);
937 let low_finding = Finding {
938 severity: Severity::Low,
939 ..make_finding(RuleId::NonAsciiHostname, Severity::Low)
940 };
941
942 let raw = Verdict {
943 action: Action::Warn,
944 findings: vec![dup_finding.clone(), dup_finding.clone(), low_finding],
945 tier_reached: 3,
946 bypass_requested: false,
947 bypass_honored: false,
948 bypass_available: false,
949 interactive_detected: false,
950 policy_path_used: None,
951 timings_ms: Timings::default(),
952 urls_extracted_count: None,
953 requires_approval: None,
954 approval_timeout_secs: None,
955 approval_fallback: None,
956 approval_rule: None,
957 approval_description: None,
958 escalation_reason: None,
959 };
960
961 let policy = crate::policy::Policy::default();
963 let result = post_process_verdict(
964 &raw,
965 &policy,
966 "test cmd",
967 "test-session",
968 CallerContext::Cli,
969 );
970
971 assert_eq!(
972 result
973 .findings
974 .iter()
975 .filter(|f| f.rule_id == RuleId::ShortenedUrl)
976 .count(),
977 2
978 );
979 }
980}