Skip to main content

cargo_bless/
bs_detector.rs

1//! BS Detector — finds hardcoded bullshit values in Rust source files.
2//!
3//! Scans `.rs` files for:
4//! - Magic numbers (non-trivial numeric literals)
5//! - Hardcoded URLs
6//! - API keys / tokens / secrets
7//! - File paths
8//! - IP addresses
9//! - Hardcoded credentials
10
11use std::fs;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16/// A detected hardcoded value.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct BSHit {
19    /// File path where the hit was found.
20    pub file: String,
21    /// Line number (1-based).
22    pub line: usize,
23    /// The raw text of the line (trimmed).
24    pub line_text: String,
25    /// Category of the BS.
26    pub category: BSCategory,
27    /// The matched value.
28    pub value: String,
29    /// Suggested fix or note.
30    pub suggestion: String,
31}
32
33/// Categories of hardcoded bullshit.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub enum BSCategory {
36    MagicNumber,
37    HardcodedUrl,
38    ApiKeyOrToken,
39    FilePath,
40    IpAddress,
41    HardcodedCredential,
42    HardcodedTimeout,
43}
44
45impl std::fmt::Display for BSCategory {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            BSCategory::MagicNumber => write!(f, "magic-number"),
49            BSCategory::HardcodedUrl => write!(f, "hardcoded-url"),
50            BSCategory::ApiKeyOrToken => write!(f, "api-key-or-token"),
51            BSCategory::FilePath => write!(f, "file-path"),
52            BSCategory::IpAddress => write!(f, "ip-address"),
53            BSCategory::HardcodedCredential => write!(f, "hardcoded-credential"),
54            BSCategory::HardcodedTimeout => write!(f, "hardcoded-timeout"),
55        }
56    }
57}
58
59/// Scan a directory tree for hardcoded bullshit values.
60pub fn scan_dir(root: &Path) -> Vec<BSHit> {
61    let mut hits = Vec::new();
62    scan_path(root, &mut hits);
63    hits.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
64    hits
65}
66
67fn scan_path(dir: &Path, hits: &mut Vec<BSHit>) {
68    let entries = match fs::read_dir(dir) {
69        Ok(e) => e,
70        Err(_) => return,
71    };
72
73    for entry in entries.flatten() {
74        let path = entry.path();
75        if path.is_dir() {
76            // Skip common non-source directories
77            let skip = ["target", ".git", "node_modules", ".cargo"];
78            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
79                if skip.contains(&name) {
80                    continue;
81                }
82            }
83            scan_path(&path, hits);
84        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
85            if let Ok(content) = fs::read_to_string(&path) {
86                for (i, line) in content.lines().enumerate() {
87                    check_line(&path, i + 1, line, hits);
88                }
89            }
90        }
91    }
92}
93
94/// Check a single line for hardcoded bullshit.
95fn check_line(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
96    let trimmed = line.trim();
97
98    // Skip comments (but still check doc comments for URLs)
99    if trimmed.starts_with("//") && !trimmed.starts_with("///") && !trimmed.starts_with("//!") {
100        return;
101    }
102
103    detect_magic_numbers(path, line_num, trimmed, hits);
104    detect_hardcoded_urls(path, line_num, trimmed, hits);
105    detect_api_keys(path, line_num, trimmed, hits);
106    detect_file_paths(path, line_num, trimmed, hits);
107    detect_ip_addresses(path, line_num, trimmed, hits);
108    detect_credentials(path, line_num, trimmed, hits);
109    detect_hardcoded_timeouts(path, line_num, trimmed, hits);
110}
111
112/// Detect magic numbers — numeric literals that aren't 0, 1, -1, 2, or common constants.
113fn detect_magic_numbers(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
114    // Skip lines that are clearly defining a constant
115    if line.contains("const ") || line.contains("static ") {
116        return;
117    }
118    if regex::Regex::new(r#"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"#)
119        .unwrap()
120        .is_match(line)
121    {
122        return;
123    }
124
125    // Match numeric literals (integers and floats)
126    let re = regex::Regex::new(r#"\b(\d{3,}|\d+\.\d+)\b"#).unwrap();
127    for cap in re.captures_iter(line) {
128        let value = &cap[1];
129
130        // Skip common non-magic values
131        let skip_values = [
132            "2024", "2025", "2026", // years
133            "1970", // epoch
134            "1000", "10000", "100000", // powers of 10
135            "255", "256", "512", "1024", // byte sizes
136            "80", "443", "8080", "8443", // common ports (handled elsewhere)
137        ];
138
139        if skip_values.contains(&value) {
140            continue;
141        }
142
143        hits.push(BSHit {
144            file: path.display().to_string(),
145            line: line_num,
146            line_text: line.to_string(),
147            category: BSCategory::MagicNumber,
148            value: value.to_string(),
149            suggestion: "Extract to a named constant with a descriptive name".to_string(),
150        });
151    }
152}
153
154/// Detect hardcoded URLs.
155fn detect_hardcoded_urls(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
156    let re = regex::Regex::new(r#"(https?://[^\s"'`<>\)\]]+)"#).unwrap();
157    for cap in re.captures_iter(line) {
158        let url = &cap[1];
159
160        // Skip documentation URLs and common non-problematic ones
161        if line.starts_with("///") || line.starts_with("//!") {
162            continue;
163        }
164        if url.contains("doc.rust-lang.org")
165            || url.contains("crates.io")
166            || url.contains("github.com")
167            || url.contains("example.com")
168        {
169            continue;
170        }
171
172        hits.push(BSHit {
173            file: path.display().to_string(),
174            line: line_num,
175            line_text: line.to_string(),
176            category: BSCategory::HardcodedUrl,
177            value: url.to_string(),
178            suggestion: "Move to a config file or environment variable".to_string(),
179        });
180    }
181}
182
183/// Detect API keys, tokens, and secrets.
184fn detect_api_keys(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
185    let re = regex::Regex::new(
186        r#"(?i)(api[_-]?key|token|secret|password|passwd|auth[_-]?token)\s*=\s*["']([^"']+)["']"#,
187    )
188    .unwrap();
189
190    for cap in re.captures_iter(line) {
191        let value = &cap[2];
192
193        // Skip placeholder values
194        if value.is_empty()
195            || value.contains("YOUR_")
196            || value.contains("REPLACE_")
197            || value.contains("CHANGE_ME")
198            || value == "..."
199            || value == "xxx"
200        {
201            continue;
202        }
203
204        hits.push(BSHit {
205            file: path.display().to_string(),
206            line: line_num,
207            line_text: line.to_string(),
208            category: BSCategory::ApiKeyOrToken,
209            value: format!("{}=***", &cap[1]),
210            suggestion: "Use an environment variable or secrets manager".to_string(),
211        });
212    }
213}
214
215/// Detect hardcoded file paths.
216fn detect_file_paths(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
217    let re = regex::Regex::new(r#"["'](/[a-zA-Z0-9_/.-]+)["']"#).unwrap();
218    for cap in re.captures_iter(line) {
219        let p = &cap[1];
220
221        // Skip common non-problematic paths
222        if p.starts_with("/usr/") || p.starts_with("/etc/") || p.starts_with("/var/") {
223            continue;
224        }
225        if p == "/" || p == "." || p == ".." {
226            continue;
227        }
228
229        hits.push(BSHit {
230            file: path.display().to_string(),
231            line: line_num,
232            line_text: line.to_string(),
233            category: BSCategory::FilePath,
234            value: p.to_string(),
235            suggestion: "Use a config file or environment variable for paths".to_string(),
236        });
237    }
238}
239
240/// Detect hardcoded IP addresses.
241fn detect_ip_addresses(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
242    let re = regex::Regex::new(r#"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b"#).unwrap();
243    for cap in re.captures_iter(line) {
244        let ip = &cap[1];
245
246        // Skip localhost and common non-problematic IPs
247        if ip == "127.0.0.1" || ip.starts_with("0.") || ip == "255.255.255.255" {
248            continue;
249        }
250
251        hits.push(BSHit {
252            file: path.display().to_string(),
253            line: line_num,
254            line_text: line.to_string(),
255            category: BSCategory::IpAddress,
256            value: ip.to_string(),
257            suggestion: "Use a hostname or configuration setting instead of hardcoded IP"
258                .to_string(),
259        });
260    }
261}
262
263/// Detect hardcoded credentials (passwords in strings).
264fn detect_credentials(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
265    let re =
266        regex::Regex::new(r#"(?i)(password|passwd|pwd)\s*[:=]\s*["']([^"']{3,})["']"#).unwrap();
267
268    for cap in re.captures_iter(line) {
269        let value = &cap[2];
270
271        // Skip placeholders
272        if value.contains("YOUR_") || value.contains("REPLACE_") || value == "..." {
273            continue;
274        }
275
276        hits.push(BSHit {
277            file: path.display().to_string(),
278            line: line_num,
279            line_text: line.to_string(),
280            category: BSCategory::HardcodedCredential,
281            value: format!("{}=***", &cap[1]),
282            suggestion:
283                "NEVER hardcode credentials. Use environment variables or a secrets manager."
284                    .to_string(),
285        });
286    }
287}
288
289/// Detect hardcoded timeouts/durations that should be configurable.
290fn detect_hardcoded_timeouts(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
291    let re = regex::Regex::new(
292        r#"(?i)(timeout|duration|interval|delay)\s*[:=]\s*(\d+)\s*(ms|seconds?|minutes?|hours?)?"#,
293    )
294    .unwrap();
295
296    for cap in re.captures_iter(line) {
297        let value = &cap[2];
298
299        // Skip trivial values
300        if value == "0" || value == "1" {
301            continue;
302        }
303
304        hits.push(BSHit {
305            file: path.display().to_string(),
306            line: line_num,
307            line_text: line.to_string(),
308            category: BSCategory::HardcodedTimeout,
309            value: format!("{} {}", value, cap.get(3).map(|m| m.as_str()).unwrap_or("")),
310            suggestion: "Make timeouts configurable via environment variables or config files"
311                .to_string(),
312        });
313    }
314}
315
316/// Render BS hits to stdout.
317pub fn render_bs_hits(hits: &[BSHit]) {
318    if hits.is_empty() {
319        println!("{}", "✅ No hardcoded bullshit detected!".green());
320        return;
321    }
322
323    use colored::*;
324
325    println!(
326        "{}",
327        format!("🚨 Found {} hardcoded value(s):", hits.len())
328            .red()
329            .bold()
330    );
331    println!();
332
333    for hit in hits {
334        let cat_tag = match hit.category {
335            BSCategory::MagicNumber => "[MAGIC]".yellow(),
336            BSCategory::HardcodedUrl => "[URL]".cyan(),
337            BSCategory::ApiKeyOrToken => "[KEY]".red(),
338            BSCategory::FilePath => "[PATH]".magenta(),
339            BSCategory::IpAddress => "[IP]".blue(),
340            BSCategory::HardcodedCredential => "[CRED]".red().bold(),
341            BSCategory::HardcodedTimeout => "[TIMEOUT]".yellow(),
342        };
343
344        println!(
345            "  {} {}:{} → {}",
346            cat_tag,
347            hit.file.dimmed(),
348            hit.line.to_string().dimmed(),
349            hit.value.yellow()
350        );
351        println!("    💡 {}", hit.suggestion.dimmed());
352    }
353
354    println!();
355    let cred_count = hits
356        .iter()
357        .filter(|h| matches!(h.category, BSCategory::HardcodedCredential))
358        .count();
359    if cred_count > 0 {
360        println!(
361            "{}",
362            format!(
363                "⚠️  {} hardcoded credential(s) found — this is a security risk!",
364                cred_count
365            )
366            .red()
367            .bold()
368        );
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use std::path::PathBuf;
376    use tempfile::TempDir;
377
378    fn create_test_file(dir: &Path, name: &str, content: &str) -> PathBuf {
379        let path = dir.join(name);
380        fs::write(&path, content).unwrap();
381        path
382    }
383
384    #[test]
385    fn test_detect_magic_number() {
386        let mut hits = Vec::new();
387        check_line(
388            Path::new("test.rs"),
389            1,
390            "let buffer_size = 4096;",
391            &mut hits,
392        );
393        assert_eq!(hits.len(), 1);
394        assert_eq!(hits[0].category, BSCategory::MagicNumber);
395        assert_eq!(hits[0].value, "4096");
396    }
397
398    #[test]
399    fn test_skip_common_numbers() {
400        let mut hits = Vec::new();
401        check_line(Path::new("test.rs"), 1, "let x = 1024;", &mut hits);
402        assert!(hits.is_empty(), "1024 should be skipped");
403
404        check_line(Path::new("test.rs"), 2, "let year = 2025;", &mut hits);
405        assert!(hits.is_empty(), "2025 should be skipped");
406    }
407
408    #[test]
409    fn test_detect_hardcoded_url() {
410        let mut hits = Vec::new();
411        check_line(
412            Path::new("test.rs"),
413            1,
414            "let url = \"https://api.example-service.com/v1/data\";",
415            &mut hits,
416        );
417        assert_eq!(
418            hits.iter()
419                .filter(|h| matches!(h.category, BSCategory::HardcodedUrl))
420                .count(),
421            1
422        );
423    }
424
425    #[test]
426    fn test_skip_doc_urls() {
427        let mut hits = Vec::new();
428        check_line(
429            Path::new("test.rs"),
430            1,
431            "/// See https://doc.rust-lang.org/std for more",
432            &mut hits,
433        );
434        assert!(hits.is_empty(), "doc URLs should be skipped");
435    }
436
437    #[test]
438    fn test_detect_api_key() {
439        let mut hits = Vec::new();
440        check_line(
441            Path::new("test.rs"),
442            1,
443            r#"let api_key = "sk-abc123def456";"#,
444            &mut hits,
445        );
446        assert_eq!(
447            hits.iter()
448                .filter(|h| matches!(h.category, BSCategory::ApiKeyOrToken))
449                .count(),
450            1
451        );
452    }
453
454    #[test]
455    fn test_skip_placeholder_api_key() {
456        let mut hits = Vec::new();
457        check_line(
458            Path::new("test.rs"),
459            1,
460            r#"let api_key = "YOUR_API_KEY_HERE";"#,
461            &mut hits,
462        );
463        assert!(hits.is_empty(), "placeholder keys should be skipped");
464    }
465
466    #[test]
467    fn test_detect_hardcoded_credential() {
468        let mut hits = Vec::new();
469        check_line(
470            Path::new("test.rs"),
471            1,
472            r#"let password = "super_secret_123";"#,
473            &mut hits,
474        );
475        assert_eq!(
476            hits.iter()
477                .filter(|h| matches!(h.category, BSCategory::HardcodedCredential))
478                .count(),
479            1
480        );
481    }
482
483    #[test]
484    fn test_detect_ip_address() {
485        let mut hits = Vec::new();
486        check_line(
487            Path::new("test.rs"),
488            1,
489            "let host = \"192.168.1.100\";",
490            &mut hits,
491        );
492        assert_eq!(
493            hits.iter()
494                .filter(|h| matches!(h.category, BSCategory::IpAddress))
495                .count(),
496            1
497        );
498    }
499
500    #[test]
501    fn test_skip_localhost() {
502        let mut hits = Vec::new();
503        check_line(
504            Path::new("test.rs"),
505            1,
506            "let host = \"127.0.0.1\";",
507            &mut hits,
508        );
509        assert!(hits.is_empty(), "localhost should be skipped");
510    }
511
512    #[test]
513    fn test_detect_hardcoded_timeout() {
514        let mut hits = Vec::new();
515        check_line(Path::new("test.rs"), 1, "let timeout = 5000ms;", &mut hits);
516        assert_eq!(
517            hits.iter()
518                .filter(|h| matches!(h.category, BSCategory::HardcodedTimeout))
519                .count(),
520            1
521        );
522    }
523
524    #[test]
525    fn test_scan_directory() {
526        let tmp = TempDir::new().unwrap();
527        create_test_file(tmp.path(), "magic.rs", "let x = 4096;\nlet y = 8192;");
528        create_test_file(tmp.path(), "keys.rs", r#"let api_key = "sk-real-key";"#);
529
530        let hits = scan_dir(tmp.path());
531        assert!(
532            hits.len() >= 3,
533            "should find at least 3 hits, got {}",
534            hits.len()
535        );
536
537        let categories: Vec<_> = hits.iter().map(|h| &h.category).collect();
538        assert!(categories.contains(&&BSCategory::MagicNumber));
539        assert!(categories.contains(&&BSCategory::ApiKeyOrToken));
540    }
541
542    #[test]
543    fn test_skip_target_directory() {
544        let tmp = TempDir::new().unwrap();
545        let target_dir = tmp.path().join("target");
546        fs::create_dir_all(&target_dir).unwrap();
547        create_test_file(&target_dir, "magic.rs", "let x = 99999;");
548
549        let hits = scan_dir(tmp.path());
550        assert!(hits.is_empty(), "target/ should be skipped");
551    }
552}