Skip to main content

safe_shell_scanner/
rules.rs

1use regex::Regex;
2
3pub struct Rule {
4    pub id: String,
5    pub description: String,
6    pub pattern: Regex,
7}
8
9pub fn built_in_rules() -> Vec<Rule> {
10    let rules_data: Vec<(&str, &str, &str)> = vec![
11        // AWS
12        ("aws-access-key", "AWS Access Key ID", r"AKIA[0-9A-Z]{16}"),
13        (
14            "aws-secret-key",
15            "AWS Secret Access Key",
16            r"(?i)aws_secret_access_key\s*=\s*\S+",
17        ),
18        (
19            "aws-session-token",
20            "AWS Session Token",
21            r"(?i)aws_session_token\s*=\s*\S+",
22        ),
23        // AI providers
24        (
25            "anthropic-api-key",
26            "Anthropic API Key",
27            r"sk-ant-[a-zA-Z0-9_-]{20,}",
28        ),
29        ("openai-api-key", "OpenAI API Key", r"sk-[a-zA-Z0-9]{20,}"),
30        (
31            "openai-project-key",
32            "OpenAI Project Key",
33            r"sk-proj-[a-zA-Z0-9_-]{20,}",
34        ),
35        // Code hosting
36        (
37            "github-pat",
38            "GitHub Personal Access Token",
39            r"ghp_[a-zA-Z0-9]{36}",
40        ),
41        ("github-oauth", "GitHub OAuth Token", r"gho_[a-zA-Z0-9]{36}"),
42        (
43            "github-fine-grained",
44            "GitHub Fine-Grained Token",
45            r"github_pat_[a-zA-Z0-9_]{22,}",
46        ),
47        (
48            "gitlab-pat",
49            "GitLab Personal Access Token",
50            r"glpat-[a-zA-Z0-9_-]{20,}",
51        ),
52        // Payment
53        (
54            "stripe-secret",
55            "Stripe Secret Key",
56            r"sk_live_[a-zA-Z0-9]{24,}",
57        ),
58        (
59            "stripe-restricted",
60            "Stripe Restricted Key",
61            r"rk_live_[a-zA-Z0-9]{24,}",
62        ),
63        // Auth tokens
64        (
65            "jwt-token",
66            "JWT Token",
67            r"eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+",
68        ),
69        (
70            "bearer-token",
71            "Bearer Token",
72            r"(?i)bearer\s+[a-zA-Z0-9_\-.]{20,}",
73        ),
74        // Private keys
75        (
76            "rsa-private-key",
77            "RSA Private Key",
78            r"-----BEGIN RSA PRIVATE KEY-----",
79        ),
80        (
81            "ec-private-key",
82            "EC Private Key",
83            r"-----BEGIN EC PRIVATE KEY-----",
84        ),
85        (
86            "pkcs8-private-key",
87            "PKCS8 Private Key",
88            r"-----BEGIN PRIVATE KEY-----",
89        ),
90        (
91            "openssh-private-key",
92            "OpenSSH Private Key",
93            r"-----BEGIN OPENSSH PRIVATE KEY-----",
94        ),
95        // Database connection strings
96        (
97            "postgres-uri",
98            "PostgreSQL Connection String",
99            r"postgres(?:ql)?://[^\s]+:[^\s]+@[^\s]+",
100        ),
101        (
102            "mysql-uri",
103            "MySQL Connection String",
104            r"mysql://[^\s]+:[^\s]+@[^\s]+",
105        ),
106        (
107            "mongodb-uri",
108            "MongoDB Connection String",
109            r"mongodb(?:\+srv)?://[^\s]+:[^\s]+@[^\s]+",
110        ),
111        (
112            "redis-uri",
113            "Redis Connection String",
114            r"redis://[^\s]*:[^\s]+@[^\s]+",
115        ),
116        // Communication
117        (
118            "slack-token",
119            "Slack Token",
120            r"xox[baprs]-[a-zA-Z0-9-]{10,}",
121        ),
122        (
123            "slack-webhook",
124            "Slack Webhook URL",
125            r"https://hooks\.slack\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+",
126        ),
127        (
128            "discord-bot-token",
129            "Discord Bot Token",
130            r"[MN][a-zA-Z0-9_-]{23,}\.[a-zA-Z0-9_-]{6}\.[a-zA-Z0-9_-]{27,}",
131        ),
132        // SaaS
133        (
134            "sendgrid-key",
135            "SendGrid API Key",
136            r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}",
137        ),
138        (
139            "vault-token",
140            "HashiCorp Vault Token",
141            r"hvs\.[a-zA-Z0-9_-]{24,}",
142        ),
143        // Generic
144        (
145            "generic-password-assign",
146            "Password Assignment",
147            r#"(?i)(?:password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{8,}"#,
148        ),
149    ];
150
151    rules_data
152        .into_iter()
153        .map(|(id, desc, pat)| Rule {
154            id: id.to_string(),
155            description: desc.to_string(),
156            pattern: Regex::new(pat).unwrap_or_else(|e| panic!("Bad regex for rule '{id}': {e}")),
157        })
158        .collect()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn all_rules_compile() {
167        let rules = built_in_rules();
168        assert!(rules.len() >= 27, "Expected 27+ rules, got {}", rules.len());
169    }
170
171    #[test]
172    fn aws_access_key() {
173        let rules = built_in_rules();
174        let rule = rules.iter().find(|r| r.id == "aws-access-key").unwrap();
175        assert!(rule.pattern.is_match("AKIAIOSFODNN7EXAMPLE"));
176        assert!(!rule.pattern.is_match("not-an-aws-key"));
177    }
178
179    #[test]
180    fn anthropic_api_key() {
181        let rules = built_in_rules();
182        let rule = rules.iter().find(|r| r.id == "anthropic-api-key").unwrap();
183        assert!(rule.pattern.is_match("sk-ant-api03-abcdefghijklmnopqrst"));
184        assert!(!rule.pattern.is_match("sk-ant-short"));
185    }
186
187    #[test]
188    fn openai_api_key() {
189        let rules = built_in_rules();
190        let rule = rules.iter().find(|r| r.id == "openai-api-key").unwrap();
191        assert!(rule.pattern.is_match("sk-abcdefghijklmnopqrstuvwx"));
192        assert!(!rule.pattern.is_match("sk-short"));
193    }
194
195    #[test]
196    fn github_pat() {
197        let rules = built_in_rules();
198        let rule = rules.iter().find(|r| r.id == "github-pat").unwrap();
199        assert!(rule
200            .pattern
201            .is_match("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"));
202        assert!(!rule.pattern.is_match("ghp_short"));
203    }
204
205    #[test]
206    fn github_fine_grained() {
207        let rules = built_in_rules();
208        let rule = rules
209            .iter()
210            .find(|r| r.id == "github-fine-grained")
211            .unwrap();
212        assert!(rule.pattern.is_match("github_pat_11ABCDEFGH0123456789AB"));
213        assert!(!rule.pattern.is_match("github_pat_short"));
214    }
215
216    #[test]
217    fn gitlab_pat() {
218        let rules = built_in_rules();
219        let rule = rules.iter().find(|r| r.id == "gitlab-pat").unwrap();
220        assert!(rule.pattern.is_match("glpat-ABCDEFghijklmnopqrstu"));
221        assert!(!rule.pattern.is_match("glpat-short"));
222    }
223
224    #[test]
225    fn stripe_secret_key() {
226        let rules = built_in_rules();
227        let rule = rules.iter().find(|r| r.id == "stripe-secret").unwrap();
228        assert!(rule.pattern.is_match("sk_live_abcdefghijklmnopqrstuvwx"));
229        assert!(!rule.pattern.is_match("sk_test_abcdefghijklmnopqrstuvwx"));
230    }
231
232    #[test]
233    fn jwt_token() {
234        let rules = built_in_rules();
235        let rule = rules.iter().find(|r| r.id == "jwt-token").unwrap();
236        assert!(rule
237            .pattern
238            .is_match("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123def456ghi789"));
239        assert!(!rule.pattern.is_match("not.a.jwt"));
240    }
241
242    #[test]
243    fn rsa_private_key() {
244        let rules = built_in_rules();
245        let rule = rules.iter().find(|r| r.id == "rsa-private-key").unwrap();
246        assert!(rule.pattern.is_match("-----BEGIN RSA PRIVATE KEY-----"));
247        assert!(!rule.pattern.is_match("-----BEGIN PUBLIC KEY-----"));
248    }
249
250    #[test]
251    fn postgres_uri() {
252        let rules = built_in_rules();
253        let rule = rules.iter().find(|r| r.id == "postgres-uri").unwrap();
254        assert!(rule
255            .pattern
256            .is_match("postgresql://user:password@localhost:5432/db"));
257        assert!(rule
258            .pattern
259            .is_match("postgres://admin:secret@prod.db.com/mydb"));
260        assert!(!rule.pattern.is_match("postgres://localhost/db"));
261    }
262
263    #[test]
264    fn mongodb_uri() {
265        let rules = built_in_rules();
266        let rule = rules.iter().find(|r| r.id == "mongodb-uri").unwrap();
267        assert!(rule
268            .pattern
269            .is_match("mongodb+srv://user:pass@cluster.mongodb.net/db"));
270        assert!(!rule.pattern.is_match("mongodb://localhost/db"));
271    }
272
273    #[test]
274    fn slack_token() {
275        let rules = built_in_rules();
276        let rule = rules.iter().find(|r| r.id == "slack-token").unwrap();
277        assert!(rule.pattern.is_match("xoxb-123456789-abcdefghij"));
278        assert!(rule.pattern.is_match("xoxp-123456789-abcdefghij"));
279        assert!(!rule.pattern.is_match("xoxb-short"));
280    }
281
282    #[test]
283    fn sendgrid_key() {
284        let rules = built_in_rules();
285        let rule = rules.iter().find(|r| r.id == "sendgrid-key").unwrap();
286        assert!(rule
287            .pattern
288            .is_match("SG.abcdefghijklmnopqrstuv.ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrst"));
289    }
290
291    #[test]
292    fn vault_token() {
293        let rules = built_in_rules();
294        let rule = rules.iter().find(|r| r.id == "vault-token").unwrap();
295        assert!(rule.pattern.is_match("hvs.ABCDEFghijklmnopqrstuvwx"));
296        assert!(!rule.pattern.is_match("hvs.short"));
297    }
298
299    #[test]
300    fn generic_password() {
301        let rules = built_in_rules();
302        let rule = rules
303            .iter()
304            .find(|r| r.id == "generic-password-assign")
305            .unwrap();
306        assert!(rule.pattern.is_match("password=mysecretpassword"));
307        assert!(rule.pattern.is_match("PASSWORD: 'longpassword123'"));
308        assert!(!rule.pattern.is_match("password=short"));
309    }
310
311    #[test]
312    fn bearer_token() {
313        let rules = built_in_rules();
314        let rule = rules.iter().find(|r| r.id == "bearer-token").unwrap();
315        assert!(rule.pattern.is_match("Bearer abcdefghijklmnopqrstuvwx"));
316        assert!(rule.pattern.is_match("bearer abcdefghijklmnopqrstuvwx"));
317        assert!(!rule.pattern.is_match("Bearer short"));
318    }
319
320    #[test]
321    fn no_false_positive_on_normal_text() {
322        let rules = built_in_rules();
323        let normal_texts = [
324            "hello world",
325            "npm install express",
326            "const x = 42;",
327            "PATH=/usr/bin",
328            "HOME=/Users/dev",
329            "NODE_ENV=production",
330        ];
331        for text in &normal_texts {
332            for rule in &rules {
333                assert!(
334                    !rule.pattern.is_match(text),
335                    "Rule '{}' false-positive on: {}",
336                    rule.id,
337                    text
338                );
339            }
340        }
341    }
342}