1use std::fs;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct BSHit {
19 pub file: String,
21 pub line: usize,
23 pub line_text: String,
25 pub category: BSCategory,
27 pub value: String,
29 pub suggestion: String,
31}
32
33#[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
59pub 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 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
94fn check_line(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
96 let trimmed = line.trim();
97
98 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
112fn detect_magic_numbers(path: &Path, line_num: usize, line: &str, hits: &mut Vec<BSHit>) {
114 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 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 let skip_values = [
132 "2024", "2025", "2026", "1970", "1000", "10000", "100000", "255", "256", "512", "1024", "80", "443", "8080", "8443", ];
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
154fn 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 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
183fn 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 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
215fn 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 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
240fn 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 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
263fn 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 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
289fn 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 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
316pub 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}