1use crate::Severity;
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[derive(Default)]
18pub enum SuppressionStatus {
19 #[default]
21 Active,
22 Expired,
24 Revoked,
26 Stale,
28 PendingApproval,
30 Rejected,
32 ScheduledRevocation,
34}
35
36impl std::fmt::Display for SuppressionStatus {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 SuppressionStatus::Active => write!(f, "active"),
40 SuppressionStatus::Expired => write!(f, "expired"),
41 SuppressionStatus::Revoked => write!(f, "revoked"),
42 SuppressionStatus::Stale => write!(f, "stale"),
43 SuppressionStatus::PendingApproval => write!(f, "pending-approval"),
44 SuppressionStatus::Rejected => write!(f, "rejected"),
45 SuppressionStatus::ScheduledRevocation => write!(f, "scheduled-revocation"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct ApprovalInfo {
53 pub required: bool,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub required_approvers: Vec<String>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub approvals: Vec<Approval>,
61 #[serde(default = "default_min_approvals")]
63 pub min_approvals: usize,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub rejection: Option<Rejection>,
67}
68
69fn default_min_approvals() -> usize {
70 1
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Approval {
76 pub approver: String,
78 pub approved_at: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub comment: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Rejection {
88 pub rejector: String,
90 pub rejected_at: String,
92 pub reason: String,
94}
95
96impl ApprovalInfo {
97 pub fn is_approved(&self) -> bool {
99 if !self.required {
100 return true;
101 }
102 self.approvals.len() >= self.min_approvals
103 }
104
105 pub fn is_rejected(&self) -> bool {
107 self.rejection.is_some()
108 }
109
110 pub fn add_approval(&mut self, approver: impl Into<String>, comment: Option<String>) {
112 self.approvals.push(Approval {
113 approver: approver.into(),
114 approved_at: chrono::Utc::now().to_rfc3339(),
115 comment,
116 });
117 }
118
119 pub fn reject(&mut self, rejector: impl Into<String>, reason: impl Into<String>) {
121 self.rejection = Some(Rejection {
122 rejector: rejector.into(),
123 rejected_at: chrono::Utc::now().to_rfc3339(),
124 reason: reason.into(),
125 });
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RevocationSchedule {
132 pub scheduled_at: String,
134 pub reason: String,
136 pub scheduled_by: String,
138 #[serde(default)]
140 pub notify_before: bool,
141 #[serde(default = "default_notify_days")]
143 pub notify_days_before: u32,
144 #[serde(default)]
146 pub grace_period_hours: u32,
147}
148
149fn default_notify_days() -> u32 {
150 7
151}
152
153impl RevocationSchedule {
154 pub fn new(
156 scheduled_at: impl Into<String>,
157 reason: impl Into<String>,
158 scheduled_by: impl Into<String>,
159 ) -> Self {
160 Self {
161 scheduled_at: scheduled_at.into(),
162 reason: reason.into(),
163 scheduled_by: scheduled_by.into(),
164 notify_before: true,
165 notify_days_before: 7,
166 grace_period_hours: 24,
167 }
168 }
169
170 pub fn is_due(&self) -> bool {
172 if let Ok(scheduled) = chrono::DateTime::parse_from_rfc3339(&self.scheduled_at) {
173 let grace = chrono::Duration::hours(self.grace_period_hours as i64);
174 return chrono::Utc::now() >= scheduled + grace;
175 }
176 false
177 }
178
179 pub fn should_notify(&self) -> bool {
181 if !self.notify_before {
182 return false;
183 }
184 if let Ok(scheduled) = chrono::DateTime::parse_from_rfc3339(&self.scheduled_at) {
185 let notify_time = scheduled - chrono::Duration::days(self.notify_days_before as i64);
186 let now = chrono::Utc::now();
187 return now >= notify_time && now < scheduled;
188 }
189 false
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct SuppressionEntry {
196 pub id: String,
198
199 pub fingerprint: String,
201
202 pub rule_id: String,
204
205 pub file_path: PathBuf,
207
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub snippet_hash: Option<String>,
211
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub context_hash: Option<String>,
215
216 pub suppressed_by: String,
218
219 pub created_at: String,
221
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub expires_at: Option<String>,
225
226 pub reason: String,
228
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub ticket_ref: Option<String>,
232
233 #[serde(default)]
235 pub status: SuppressionStatus,
236
237 #[serde(default)]
239 pub original_severity: Severity,
240
241 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
244 pub tags: HashSet<String>,
245
246 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
248 pub groups: HashSet<String>,
249
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub approval: Option<ApprovalInfo>,
253
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub scheduled_revocation: Option<RevocationSchedule>,
257
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub policy_id: Option<String>,
261
262 #[serde(default = "default_priority")]
264 pub priority: u8,
265
266 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
268 pub metadata: std::collections::HashMap<String, String>,
269
270 #[serde(default)]
272 pub version: u32,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub updated_at: Option<String>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub updated_by: Option<String>,
281}
282
283fn default_priority() -> u8 {
284 3
285}
286
287impl SuppressionEntry {
288 pub fn new(
290 fingerprint: impl Into<String>,
291 rule_id: impl Into<String>,
292 file_path: impl Into<PathBuf>,
293 suppressed_by: impl Into<String>,
294 reason: impl Into<String>,
295 ) -> Self {
296 Self {
297 id: uuid_v4(),
298 fingerprint: fingerprint.into(),
299 rule_id: rule_id.into(),
300 file_path: file_path.into(),
301 snippet_hash: None,
302 context_hash: None,
303 suppressed_by: suppressed_by.into(),
304 created_at: chrono::Utc::now().to_rfc3339(),
305 expires_at: None,
306 reason: reason.into(),
307 ticket_ref: None,
308 status: SuppressionStatus::Active,
309 original_severity: Severity::Warning,
310 tags: HashSet::new(),
311 groups: HashSet::new(),
312 approval: None,
313 scheduled_revocation: None,
314 policy_id: None,
315 priority: 3,
316 metadata: std::collections::HashMap::new(),
317 version: 1,
318 updated_at: None,
319 updated_by: None,
320 }
321 }
322
323 pub fn with_snippet(mut self, snippet: impl AsRef<str>) -> Self {
325 self.snippet_hash = Some(hash_snippet(snippet.as_ref()));
326 self
327 }
328
329 pub fn with_snippet_hash(mut self, hash: impl Into<String>) -> Self {
331 self.snippet_hash = Some(hash.into());
332 self
333 }
334
335 pub fn with_context_hash(mut self, hash: impl Into<String>) -> Self {
337 self.context_hash = Some(hash.into());
338 self
339 }
340
341 pub fn with_expiration(mut self, expires_at: impl Into<String>) -> Self {
343 self.expires_at = Some(expires_at.into());
344 self
345 }
346
347 pub fn with_expiration_days(mut self, days: u32) -> Self {
349 let expires = chrono::Utc::now() + chrono::Duration::days(days as i64);
350 self.expires_at = Some(expires.to_rfc3339());
351 self
352 }
353
354 pub fn with_ticket(mut self, ticket: impl Into<String>) -> Self {
356 self.ticket_ref = Some(ticket.into());
357 self
358 }
359
360 pub fn with_severity(mut self, severity: Severity) -> Self {
362 self.original_severity = severity;
363 self
364 }
365
366 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
368 self.tags.insert(tag.into());
369 self
370 }
371
372 pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
374 for tag in tags {
375 self.tags.insert(tag.into());
376 }
377 self
378 }
379
380 pub fn with_group(mut self, group: impl Into<String>) -> Self {
382 self.groups.insert(group.into());
383 self
384 }
385
386 pub fn with_priority(mut self, priority: u8) -> Self {
388 self.priority = priority.clamp(1, 5);
389 self
390 }
391
392 pub fn with_policy(mut self, policy_id: impl Into<String>) -> Self {
394 self.policy_id = Some(policy_id.into());
395 self
396 }
397
398 pub fn require_approval(mut self, min_approvals: usize) -> Self {
400 self.approval = Some(ApprovalInfo {
401 required: true,
402 required_approvers: Vec::new(),
403 approvals: Vec::new(),
404 min_approvals,
405 rejection: None,
406 });
407 self.status = SuppressionStatus::PendingApproval;
408 self
409 }
410
411 pub fn require_approval_from(
413 mut self,
414 approvers: impl IntoIterator<Item = impl Into<String>>,
415 ) -> Self {
416 self.approval = Some(ApprovalInfo {
417 required: true,
418 required_approvers: approvers.into_iter().map(|a| a.into()).collect(),
419 approvals: Vec::new(),
420 min_approvals: 1,
421 rejection: None,
422 });
423 self.status = SuppressionStatus::PendingApproval;
424 self
425 }
426
427 pub fn schedule_revocation(
429 mut self,
430 scheduled_at: impl Into<String>,
431 reason: impl Into<String>,
432 scheduled_by: impl Into<String>,
433 ) -> Self {
434 self.scheduled_revocation =
435 Some(RevocationSchedule::new(scheduled_at, reason, scheduled_by));
436 self
437 }
438
439 pub fn schedule_revocation_days(
441 mut self,
442 days: u32,
443 reason: impl Into<String>,
444 scheduled_by: impl Into<String>,
445 ) -> Self {
446 let scheduled = chrono::Utc::now() + chrono::Duration::days(days as i64);
447 self.scheduled_revocation = Some(RevocationSchedule::new(
448 scheduled.to_rfc3339(),
449 reason,
450 scheduled_by,
451 ));
452 self
453 }
454
455 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
457 self.metadata.insert(key.into(), value.into());
458 self
459 }
460
461 pub fn is_expired(&self) -> bool {
463 if let Some(ref expires_at) = self.expires_at
464 && let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires_at)
465 {
466 return expiry < chrono::Utc::now();
467 }
468 false
469 }
470
471 pub fn is_active(&self) -> bool {
473 matches!(self.status, SuppressionStatus::Active) && !self.is_expired()
474 }
475
476 pub fn is_pending_approval(&self) -> bool {
478 self.status == SuppressionStatus::PendingApproval
479 }
480
481 pub fn is_approved(&self) -> bool {
483 self.approval
484 .as_ref()
485 .map(|a| a.is_approved())
486 .unwrap_or(true)
487 }
488
489 pub fn approve(&mut self, approver: impl Into<String>, comment: Option<String>) {
491 if let Some(ref mut approval) = self.approval {
492 approval.add_approval(approver, comment);
493 if approval.is_approved() {
494 self.status = SuppressionStatus::Active;
495 }
496 }
497 self.touch("approver");
498 }
499
500 pub fn reject(&mut self, rejector: impl Into<String>, reason: impl Into<String>) {
502 if let Some(ref mut approval) = self.approval {
503 approval.reject(rejector.into(), reason);
504 }
505 self.status = SuppressionStatus::Rejected;
506 self.touch("rejector");
507 }
508
509 pub fn revoke(&mut self) {
511 self.status = SuppressionStatus::Revoked;
512 }
513
514 pub fn mark_stale(&mut self) {
516 self.status = SuppressionStatus::Stale;
517 }
518
519 pub fn reactivate(&mut self, actor: impl Into<String>) {
521 self.status = SuppressionStatus::Active;
522 self.touch(actor);
523 }
524
525 pub fn set_scheduled_revocation(
527 &mut self,
528 scheduled_at: impl Into<String>,
529 reason: impl Into<String>,
530 scheduled_by: impl Into<String>,
531 ) {
532 self.scheduled_revocation = Some(RevocationSchedule::new(
533 scheduled_at,
534 reason,
535 scheduled_by.into(),
536 ));
537 self.status = SuppressionStatus::ScheduledRevocation;
538 }
539
540 pub fn cancel_scheduled_revocation(&mut self) {
542 self.scheduled_revocation = None;
543 if self.status == SuppressionStatus::ScheduledRevocation {
544 self.status = SuppressionStatus::Active;
545 }
546 }
547
548 pub fn add_approval(&mut self, approver: impl Into<String>, comment: Option<&str>) {
550 if let Some(ref mut approval) = self.approval {
551 approval.add_approval(approver, comment.map(|s| s.to_string()));
552 if approval.is_approved() {
553 self.status = SuppressionStatus::Active;
554 }
555 }
556 self.touch("approver");
557 }
558
559 fn touch(&mut self, actor: impl Into<String>) {
561 self.updated_at = Some(chrono::Utc::now().to_rfc3339());
562 self.updated_by = Some(actor.into());
563 self.version += 1;
564 }
565
566 pub fn is_stale(&self, current_snippet: Option<&str>) -> bool {
568 match (&self.snippet_hash, current_snippet) {
569 (Some(original_hash), Some(current)) => {
570 let current_hash = hash_snippet(current);
571 original_hash != ¤t_hash
572 }
573 (Some(_), None) => true,
574 (None, _) => false,
575 }
576 }
577
578 pub fn is_stale_by_hash(&self, current_snippet_hash: Option<&str>) -> bool {
580 match (&self.snippet_hash, current_snippet_hash) {
581 (Some(original), Some(current)) => original != current,
582 (Some(_), None) => true,
583 (None, _) => false,
584 }
585 }
586
587 pub fn is_revocation_due(&self) -> bool {
589 self.scheduled_revocation
590 .as_ref()
591 .map(|s| s.is_due())
592 .unwrap_or(false)
593 }
594
595 pub fn time_until_expiry(&self) -> Option<String> {
597 if let Some(ref expires_at) = self.expires_at
598 && let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires_at)
599 {
600 let now = chrono::Utc::now();
601 if expiry < now {
602 return Some("expired".to_string());
603 }
604 let duration = expiry.signed_duration_since(now);
605 let days = duration.num_days();
606 if days > 0 {
607 return Some(format!("{}d", days));
608 }
609 let hours = duration.num_hours();
610 if hours > 0 {
611 return Some(format!("{}h", hours));
612 }
613 return Some("< 1h".to_string());
614 }
615 None
616 }
617
618 pub fn has_tag(&self, tag: &str) -> bool {
620 self.tags.contains(tag)
621 }
622
623 pub fn in_group(&self, group: &str) -> bool {
625 self.groups.contains(group)
626 }
627
628 pub fn tags_sorted(&self) -> Vec<&str> {
630 let mut tags: Vec<_> = self.tags.iter().map(|s| s.as_str()).collect();
631 tags.sort();
632 tags
633 }
634
635 pub fn groups_sorted(&self) -> Vec<&str> {
637 let mut groups: Vec<_> = self.groups.iter().map(|s| s.as_str()).collect();
638 groups.sort();
639 groups
640 }
641}
642
643fn uuid_v4() -> String {
645 use std::time::{SystemTime, UNIX_EPOCH};
646
647 let timestamp = SystemTime::now()
648 .duration_since(UNIX_EPOCH)
649 .unwrap_or_default()
650 .as_nanos();
651
652 let mut hasher = std::collections::hash_map::DefaultHasher::new();
653 std::hash::Hash::hash(×tamp, &mut hasher);
654 std::hash::Hash::hash(&std::process::id(), &mut hasher);
655 let random = std::hash::Hasher::finish(&hasher);
656
657 format!(
658 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
659 (timestamp & 0xFFFFFFFF) as u32,
660 ((timestamp >> 32) & 0xFFFF) as u16,
661 (random & 0xFFF) as u16,
662 ((random >> 12) & 0x3FFF | 0x8000) as u16,
663 (random >> 26) & 0xFFFFFFFFFFFF
664 )
665}
666
667pub fn hash_snippet(snippet: &str) -> String {
669 use sha2::{Digest, Sha256};
670
671 let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
673
674 let mut hasher = Sha256::new();
675 hasher.update(normalized.as_bytes());
676 let result = hasher.finalize();
677
678 format!("{:x}", result)[..16].to_string()
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[test]
686 fn test_new_suppression() {
687 let entry = SuppressionEntry::new(
688 "sha256:abc123",
689 "generic/hardcoded-secret",
690 "src/test.rs",
691 "admin",
692 "Test fixture",
693 );
694
695 assert!(!entry.id.is_empty());
696 assert_eq!(entry.fingerprint, "sha256:abc123");
697 assert_eq!(entry.rule_id, "generic/hardcoded-secret");
698 assert_eq!(entry.file_path, PathBuf::from("src/test.rs"));
699 assert_eq!(entry.suppressed_by, "admin");
700 assert_eq!(entry.reason, "Test fixture");
701 assert_eq!(entry.status, SuppressionStatus::Active);
702 assert!(entry.is_active());
703 assert!(entry.snippet_hash.is_none());
704 }
705
706 #[test]
707 fn test_expiration() {
708 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
709 .with_expiration_days(30);
710
711 assert!(!entry.is_expired());
712 assert!(entry.is_active());
713 assert!(entry.expires_at.is_some());
714 assert!(entry.time_until_expiry().is_some());
715 }
716
717 #[test]
718 fn test_staleness_detection() {
719 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
720 .with_snippet("let password = \"secret\";");
721
722 assert!(entry.snippet_hash.is_some());
723 assert!(!entry.is_stale(Some("let password = \"secret\";")));
724 assert!(!entry.is_stale(Some("let password = \"secret\";")));
725 assert!(entry.is_stale(Some("let password = \"different\";")));
726 assert!(entry.is_stale(None));
727 }
728
729 #[test]
730 fn test_hash_snippet() {
731 let hash1 = hash_snippet("let x = 1;");
732 let hash2 = hash_snippet("let x = 1;");
733 let hash3 = hash_snippet("let x = 1;");
734 let hash4 = hash_snippet("let y = 2;");
735
736 assert_eq!(hash1, hash2);
737 assert_eq!(hash1, hash3);
738 assert_ne!(hash1, hash4);
739 }
740
741 #[test]
742 fn test_revoke() {
743 let mut entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason");
744
745 assert!(entry.is_active());
746 entry.revoke();
747 assert!(!entry.is_active());
748 assert_eq!(entry.status, SuppressionStatus::Revoked);
749 }
750
751 #[test]
752 fn test_tags_and_groups() {
753 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
754 .with_tag("security")
755 .with_tag("false-positive")
756 .with_group("team-backend");
757
758 assert!(entry.has_tag("security"));
759 assert!(entry.has_tag("false-positive"));
760 assert!(!entry.has_tag("other"));
761 assert!(entry.in_group("team-backend"));
762 assert!(!entry.in_group("team-frontend"));
763 }
764
765 #[test]
766 fn test_approval_workflow() {
767 let mut entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
768 .require_approval(2);
769
770 assert!(entry.is_pending_approval());
771 assert!(!entry.is_approved());
772
773 entry.approve("approver1", Some("Looks good".to_string()));
774 assert!(entry.is_pending_approval()); entry.approve("approver2", None);
777 assert!(entry.is_approved());
778 assert!(entry.is_active());
779 }
780
781 #[test]
782 fn test_approval_rejection() {
783 let mut entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
784 .require_approval(1);
785
786 entry.reject("security-team", "This is a real vulnerability");
787 assert_eq!(entry.status, SuppressionStatus::Rejected);
788 assert!(!entry.is_active());
789 }
790
791 #[test]
792 fn test_scheduled_revocation() {
793 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
794 .schedule_revocation_days(30, "Temporary suppression", "admin");
795
796 assert!(entry.scheduled_revocation.is_some());
797 assert!(!entry.is_revocation_due());
798 }
799
800 #[test]
801 fn test_priority() {
802 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
803 .with_priority(1);
804
805 assert_eq!(entry.priority, 1);
806
807 let entry2 = entry.with_priority(10); assert_eq!(entry2.priority, 5);
809 }
810
811 #[test]
812 fn test_metadata() {
813 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
814 .with_metadata("custom_field", "custom_value")
815 .with_metadata("another", "value");
816
817 assert_eq!(
818 entry.metadata.get("custom_field"),
819 Some(&"custom_value".to_string())
820 );
821 assert_eq!(entry.metadata.get("another"), Some(&"value".to_string()));
822 }
823}