1use crate::{
12 Confidence, Exploitability, Finding, MirPackage, Rule, RuleMetadata, RuleOrigin, Severity,
13};
14use std::ffi::OsStr;
15use std::fs;
16use std::path::Path;
17use walkdir::WalkDir;
18
19pub(crate) fn line_contains_md5_usage(line: &str) -> bool {
24 let lower = line.to_lowercase();
25 let mut search_start = 0;
26
27 while let Some(relative_idx) = lower[search_start..].find("md5") {
28 let idx = search_start + relative_idx;
29
30 let mut before_chars = lower[..idx].chars().rev().skip_while(|c| c.is_whitespace());
31 let mut after_chars = lower[idx + 3..].chars().skip_while(|c| c.is_whitespace());
32
33 let after_matches = matches!(
34 (after_chars.next(), after_chars.next()),
35 (Some(':'), Some(':'))
36 );
37
38 let before_first = before_chars.next();
39 let before_second = before_chars.next();
40 let before_matches = matches!((before_first, before_second), (Some(':'), Some(':')));
41
42 if before_matches || after_matches {
43 return true;
44 }
45
46 search_start = idx + 3;
47 }
48
49 false
50}
51
52pub(crate) fn line_contains_sha1_usage(line: &str) -> bool {
53 let lower = line.to_lowercase();
54 lower.contains("sha1::") || lower.contains("::sha1")
55}
56
57pub(crate) fn line_contains_weak_hash_extended(line: &str) -> bool {
58 let lower = line.to_lowercase();
59
60 if lower.contains("= [const \"") || lower.contains("const \"") {
62 return false;
63 }
64 if lower.starts_with("0x") || (lower.contains("0x") && lower.contains("│")) {
65 return false;
66 }
67
68 if lower.contains("ripemd") {
70 if lower.contains("ripemd::")
71 || lower.contains("::ripemd")
72 || lower.contains("ripemd128")
73 || lower.contains("ripemd160")
74 || lower.contains("ripemd256")
75 || lower.contains("ripemd320")
76 {
77 return true;
78 }
79 }
80
81 if lower.contains("crc") {
83 if lower.contains("crc::")
84 || lower.contains("::crc")
85 || lower.contains("crc32")
86 || lower.contains("crc_32")
87 || lower.contains("crc16")
88 || lower.contains("crc_16")
89 || lower.contains("crc64")
90 || lower.contains("crc_64")
91 {
92 return true;
93 }
94 }
95
96 if lower.contains("adler")
98 && (lower.contains("adler::") || lower.contains("::adler") || lower.contains("adler32"))
99 {
100 return true;
101 }
102
103 false
104}
105
106fn filter_entry(entry: &walkdir::DirEntry) -> bool {
107 let name = entry.file_name().to_string_lossy();
108 !name.starts_with('.') && name != "target"
109}
110
111pub struct InsecureMd5Rule {
116 metadata: RuleMetadata,
117}
118
119impl InsecureMd5Rule {
120 pub fn new() -> Self {
121 Self {
122 metadata: RuleMetadata {
123 id: "RUSTCOLA004".to_string(),
124 name: "insecure-hash-md5".to_string(),
125 short_description: "Usage of MD5 hashing".to_string(),
126 full_description: "Detects calls into md5 hashing APIs which are considered cryptographically broken.".to_string(),
127 help_uri: None,
128 default_severity: Severity::High,
129 origin: RuleOrigin::BuiltIn,
130 cwe_ids: Vec::new(),
131 fix_suggestion: None,
132 exploitability: Exploitability::default(),
133 },
134 }
135 }
136}
137
138impl Rule for InsecureMd5Rule {
139 fn metadata(&self) -> &RuleMetadata {
140 &self.metadata
141 }
142
143 fn evaluate(
144 &self,
145 package: &MirPackage,
146 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
147 ) -> Vec<Finding> {
148 let mut findings = Vec::new();
149
150 for function in &package.functions {
151 let evidence: Vec<String> = function
152 .body
153 .iter()
154 .filter(|line| line_contains_md5_usage(line))
155 .map(|line| line.trim().to_string())
156 .collect();
157 if evidence.is_empty() {
158 continue;
159 }
160
161 findings.push(Finding {
162 rule_id: self.metadata.id.clone(),
163 rule_name: self.metadata.name.clone(),
164 severity: self.metadata.default_severity,
165 message: format!("Insecure MD5 hashing detected in `{}`", function.name),
166 function: function.name.clone(),
167 function_signature: function.signature.clone(),
168 evidence,
169 span: function.span.clone(),
170 confidence: Confidence::Medium,
171 cwe_ids: Vec::new(),
172 fix_suggestion: None,
173 code_snippet: None,
174 exploitability: Exploitability::default(),
175 exploitability_score: Exploitability::default().score(),
176 ..Default::default()
177 });
178 }
179
180 findings
181 }
182}
183
184pub struct InsecureSha1Rule {
189 metadata: RuleMetadata,
190}
191
192impl InsecureSha1Rule {
193 pub fn new() -> Self {
194 Self {
195 metadata: RuleMetadata {
196 id: "RUSTCOLA005".to_string(),
197 name: "insecure-hash-sha1".to_string(),
198 short_description: "Usage of SHA-1 hashing".to_string(),
199 full_description: "Detects SHA-1 hashing APIs which are deprecated for security-sensitive contexts.".to_string(),
200 help_uri: None,
201 default_severity: Severity::High,
202 origin: RuleOrigin::BuiltIn,
203 cwe_ids: Vec::new(),
204 fix_suggestion: None,
205 exploitability: Exploitability::default(),
206 },
207 }
208 }
209}
210
211impl Rule for InsecureSha1Rule {
212 fn metadata(&self) -> &RuleMetadata {
213 &self.metadata
214 }
215
216 fn evaluate(
217 &self,
218 package: &MirPackage,
219 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
220 ) -> Vec<Finding> {
221 let mut findings = Vec::new();
222
223 for function in &package.functions {
224 if function.name.contains("line_contains_sha1_usage") {
225 continue;
226 }
227
228 let evidence: Vec<String> = function
229 .body
230 .iter()
231 .filter(|line| line_contains_sha1_usage(line))
232 .map(|line| line.trim().to_string())
233 .collect();
234 if evidence.is_empty() {
235 continue;
236 }
237
238 findings.push(Finding {
239 rule_id: self.metadata.id.clone(),
240 rule_name: self.metadata.name.clone(),
241 severity: self.metadata.default_severity,
242 message: format!("Insecure SHA-1 hashing detected in `{}`", function.name),
243 function: function.name.clone(),
244 function_signature: function.signature.clone(),
245 evidence,
246 span: function.span.clone(),
247 confidence: Confidence::Medium,
248 cwe_ids: Vec::new(),
249 fix_suggestion: None,
250 code_snippet: None,
251 exploitability: Exploitability::default(),
252 exploitability_score: Exploitability::default().score(),
253 ..Default::default()
254 });
255 }
256
257 findings
258 }
259}
260
261pub struct WeakHashingExtendedRule {
266 metadata: RuleMetadata,
267}
268
269impl WeakHashingExtendedRule {
270 pub fn new() -> Self {
271 Self {
272 metadata: RuleMetadata {
273 id: "RUSTCOLA062".to_string(),
274 name: "weak-hashing-extended".to_string(),
275 short_description: "Usage of weak cryptographic hash algorithms".to_string(),
276 full_description: "Detects usage of weak or deprecated cryptographic hash algorithms beyond MD5/SHA-1, including RIPEMD (all variants), CRC32, CRC32Fast, and Adler32. These algorithms should not be used for security-sensitive operations like password hashing, authentication tokens, or cryptographic signatures. Use SHA-256, SHA-3, BLAKE2, or BLAKE3 instead.".to_string(),
277 help_uri: None,
278 default_severity: Severity::High,
279 origin: RuleOrigin::BuiltIn,
280 cwe_ids: Vec::new(),
281 fix_suggestion: None,
282 exploitability: Exploitability::default(),
283 },
284 }
285 }
286}
287
288impl Rule for WeakHashingExtendedRule {
289 fn metadata(&self) -> &RuleMetadata {
290 &self.metadata
291 }
292
293 fn evaluate(
294 &self,
295 package: &MirPackage,
296 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
297 ) -> Vec<Finding> {
298 if package.crate_name == "mir-extractor" {
299 return Vec::new();
300 }
301
302 let mut findings = Vec::new();
303
304 for function in &package.functions {
305 if function.name.contains("WeakHashingExtendedRule")
306 || function.name.contains("line_contains_weak_hash_extended")
307 {
308 continue;
309 }
310
311 let evidence: Vec<String> = function
312 .body
313 .iter()
314 .filter(|line| line_contains_weak_hash_extended(line))
315 .map(|line| line.trim().to_string())
316 .collect();
317
318 if evidence.is_empty() {
319 continue;
320 }
321
322 findings.push(Finding {
323 rule_id: self.metadata.id.clone(),
324 rule_name: self.metadata.name.clone(),
325 severity: self.metadata.default_severity,
326 message: format!(
327 "Weak cryptographic hash algorithm detected in `{}`",
328 function.name
329 ),
330 function: function.name.clone(),
331 function_signature: function.signature.clone(),
332 evidence,
333 span: function.span.clone(),
334 confidence: Confidence::Medium,
335 cwe_ids: Vec::new(),
336 fix_suggestion: None,
337 code_snippet: None,
338 exploitability: Exploitability::default(),
339 exploitability_score: Exploitability::default().score(),
340 ..Default::default()
341 });
342 }
343
344 findings
345 }
346}
347
348pub struct HardcodedCryptoKeyRule {
353 metadata: RuleMetadata,
354}
355
356impl HardcodedCryptoKeyRule {
357 pub fn new() -> Self {
358 Self {
359 metadata: RuleMetadata {
360 id: "RUSTCOLA039".to_string(),
361 name: "hardcoded-crypto-key".to_string(),
362 short_description: "Hard-coded cryptographic key or IV".to_string(),
363 full_description: "Detects hard-coded cryptographic keys, initialization vectors, or secrets in source code. Embedded secrets cannot be rotated without code changes, enable credential theft if the binary is reverse-engineered, and violate security best practices. Use environment variables, configuration files, or secret management services instead.".to_string(),
364 help_uri: Some("https://cwe.mitre.org/data/definitions/798.html".to_string()),
365 default_severity: Severity::High,
366 origin: RuleOrigin::BuiltIn,
367 cwe_ids: Vec::new(),
368 fix_suggestion: None,
369 exploitability: Exploitability::default(),
370 },
371 }
372 }
373
374 fn crypto_key_patterns() -> &'static [&'static str] {
375 &[
376 "Aes128::new",
377 "Aes192::new",
378 "Aes256::new",
379 "ChaCha20::new",
380 "ChaCha20Poly1305::new",
381 "Hmac::new_from_slice",
382 "GenericArray::from_slice",
383 "Key::from_slice",
384 "Cipher::new",
385 ]
386 }
387
388 fn suspicious_var_names() -> &'static [&'static str] {
389 &["key", "secret", "password", "token", "iv", "nonce", "salt"]
390 }
391
392 fn is_suspicious_assignment(line: &str, pattern: &str) -> bool {
393 let lower_line = line.to_lowercase();
394 let lower_pattern = pattern.to_lowercase();
395
396 if !lower_line.contains(&lower_pattern) {
397 return false;
398 }
399
400 if !line.contains('=') {
401 return false;
402 }
403
404 let parts: Vec<&str> = line.splitn(2, '=').collect();
405 if parts.len() != 2 {
406 return false;
407 }
408
409 let left_side = parts[0].trim().to_lowercase();
410 let right_side = parts[1].trim();
411
412 let has_pattern_in_identifier = Self::has_word_boundary_match(&left_side, &lower_pattern);
413
414 if !has_pattern_in_identifier {
415 return false;
416 }
417
418 if right_side.contains("b\"") || right_side.contains("b'") {
420 return true;
421 }
422 if right_side.contains("&[") || right_side.contains("[0x") || right_side.contains("[0u8") {
423 return true;
424 }
425 if right_side.starts_with('"') && right_side.len() > 30 {
426 if Self::is_likely_url_path(right_side) {
428 return false;
429 }
430 return true;
431 }
432 if right_side.starts_with('"')
433 && right_side.chars().filter(|c| c.is_ascii_hexdigit()).count() > 20
434 {
435 return true;
436 }
437
438 false
439 }
440
441 fn is_likely_url_path(value: &str) -> bool {
443 let lower = value.to_lowercase();
444 lower.contains("\"/") || lower.contains("http://") ||
447 lower.contains("https://") ||
448 lower.contains("/api/") ||
449 lower.contains("/v1/") ||
450 lower.contains("/v2/") ||
451 lower.contains("/v3/") ||
452 lower.contains("/v4/") ||
453 lower.contains("/auth/") ||
454 lower.contains("/oauth/") ||
455 lower.contains("/token/") || lower.contains("/configure/") ||
457 lower.contains("/admin/")
458 }
459
460 fn has_word_boundary_match(text: &str, pattern: &str) -> bool {
461 if let Some(pos) = text.find(pattern) {
462 let before_ok = if pos == 0 {
463 true
464 } else {
465 let char_before = text.chars().nth(pos - 1).unwrap_or(' ');
466 !char_before.is_alphanumeric() || char_before == '_'
467 };
468
469 let after_pos = pos + pattern.len();
470 let after_ok = if after_pos >= text.len() {
471 true
472 } else {
473 let char_after = text.chars().nth(after_pos).unwrap_or(' ');
474 !char_after.is_alphanumeric()
475 };
476
477 before_ok && after_ok
478 } else {
479 false
480 }
481 }
482}
483
484impl Rule for HardcodedCryptoKeyRule {
485 fn metadata(&self) -> &RuleMetadata {
486 &self.metadata
487 }
488
489 fn evaluate(
490 &self,
491 package: &MirPackage,
492 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
493 ) -> Vec<Finding> {
494 if package.crate_name == "mir-extractor" {
495 return Vec::new();
496 }
497
498 let mut findings = Vec::new();
499 let crate_root = Path::new(&package.crate_root);
500
501 if !crate_root.exists() {
502 return findings;
503 }
504
505 for entry in WalkDir::new(crate_root)
506 .into_iter()
507 .filter_entry(|e| filter_entry(e))
508 {
509 let entry = match entry {
510 Ok(e) => e,
511 Err(_) => continue,
512 };
513
514 if !entry.file_type().is_file() {
515 continue;
516 }
517
518 let path = entry.path();
519 if path.extension() != Some(OsStr::new("rs")) {
520 continue;
521 }
522
523 let rel_path = path
524 .strip_prefix(crate_root)
525 .unwrap_or(path)
526 .to_string_lossy()
527 .replace('\\', "/");
528
529 let content = match fs::read_to_string(path) {
530 Ok(c) => c,
531 Err(_) => continue,
532 };
533
534 let lines: Vec<&str> = content.lines().collect();
535
536 for (idx, line) in lines.iter().enumerate() {
537 let trimmed = line.trim();
538
539 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
540 continue;
541 }
542
543 for pattern in Self::crypto_key_patterns() {
544 if trimmed.contains(pattern) {
545 if trimmed.contains("b\"")
546 || trimmed.contains("&[")
547 || trimmed.contains("[0x")
548 {
549 let location = format!("{}:{}", rel_path, idx + 1);
550
551 findings.push(Finding::new(
552 self.metadata.id.clone(),
553 self.metadata.name.clone(),
554 self.metadata.default_severity,
555 "Hard-coded cryptographic key or IV detected in source code",
556 location,
557 pattern.to_string(),
558 vec![trimmed.to_string()],
559 None,
560 ));
561 }
562 }
563 }
564
565 for var_pattern in Self::suspicious_var_names() {
566 if Self::is_suspicious_assignment(trimmed, var_pattern) {
567 let location = format!("{}:{}", rel_path, idx + 1);
568
569 findings.push(Finding::new(
570 self.metadata.id.clone(),
571 self.metadata.name.clone(),
572 self.metadata.default_severity,
573 format!(
574 "Potential hard-coded secret in variable containing '{}'",
575 var_pattern
576 ),
577 location,
578 var_pattern.to_string(),
579 vec![trimmed.to_string()],
580 None,
581 ));
582 }
583 }
584 }
585 }
586
587 findings
588 }
589}
590
591pub struct TimingAttackRule {
596 metadata: RuleMetadata,
597}
598
599impl TimingAttackRule {
600 pub fn new() -> Self {
601 Self {
602 metadata: RuleMetadata {
603 id: "RUSTCOLA044".to_string(),
604 name: "timing-attack-secret-comparison".to_string(),
605 short_description: "Non-constant-time secret comparison".to_string(),
606 full_description: "Detects comparisons of secrets (passwords, tokens, HMACs) using non-constant-time operations like == or .starts_with(). These can leak information through timing side-channels. Use constant_time_eq or subtle::ConstantTimeEq instead.".to_string(),
607 help_uri: Some("https://codahale.com/a-lesson-in-timing-attacks/".to_string()),
608 default_severity: Severity::High,
609 origin: RuleOrigin::BuiltIn,
610 cwe_ids: Vec::new(),
611 fix_suggestion: None,
612 exploitability: Exploitability::default(),
613 },
614 }
615 }
616
617 fn looks_like_secret(var_name: &str) -> bool {
618 let lowered = var_name.to_lowercase();
619 let secret_markers = [
620 "password",
621 "passwd",
622 "pwd",
623 "token",
624 "secret",
625 "key",
626 "hmac",
627 "signature",
628 "auth",
629 "credential",
630 "api_key",
631 "apikey",
632 ];
633 secret_markers.iter().any(|marker| lowered.contains(marker))
634 }
635
636 fn is_non_constant_time_comparison(line: &str) -> bool {
637 let lowered = line.to_lowercase();
638
639 if lowered.contains("constant_time_eq")
640 || lowered.contains("constanttimeeq")
641 || lowered.contains("subtle::")
642 {
643 return false;
644 }
645
646 if lowered.contains(" == ")
647 || lowered.contains(" != ")
648 || lowered.contains(".eq(")
649 || lowered.contains(".ne(")
650 || lowered.contains(".starts_with(")
651 || lowered.contains(".ends_with(")
652 {
653 let words: Vec<&str> = line
654 .split(&[' ', '(', ')', ',', ';', '=', '!'][..])
655 .filter(|w| !w.is_empty())
656 .collect();
657
658 return words.iter().any(|w| Self::looks_like_secret(w));
659 }
660
661 false
662 }
663}
664
665impl Rule for TimingAttackRule {
666 fn metadata(&self) -> &RuleMetadata {
667 &self.metadata
668 }
669
670 fn evaluate(
671 &self,
672 package: &MirPackage,
673 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
674 ) -> Vec<Finding> {
675 if package.crate_name == "mir-extractor" {
676 return Vec::new();
677 }
678
679 let mut findings = Vec::new();
680 let crate_root = Path::new(&package.crate_root);
681
682 if !crate_root.exists() {
683 return findings;
684 }
685
686 for entry in WalkDir::new(crate_root)
687 .into_iter()
688 .filter_entry(|e| filter_entry(e))
689 {
690 let entry = match entry {
691 Ok(e) => e,
692 Err(_) => continue,
693 };
694
695 if !entry.file_type().is_file() {
696 continue;
697 }
698
699 let path = entry.path();
700 if path.extension() != Some(OsStr::new("rs")) {
701 continue;
702 }
703
704 let rel_path = path
705 .strip_prefix(crate_root)
706 .unwrap_or(path)
707 .to_string_lossy()
708 .replace('\\', "/");
709
710 let content = match fs::read_to_string(path) {
711 Ok(c) => c,
712 Err(_) => continue,
713 };
714
715 for (idx, line) in content.lines().enumerate() {
716 if line.trim().starts_with("//") {
717 continue;
718 }
719
720 if Self::is_non_constant_time_comparison(line) {
721 let location = format!("{}:{}", rel_path, idx + 1);
722
723 findings.push(Finding::new(
724 self.metadata.id.clone(),
725 self.metadata.name.clone(),
726 self.metadata.default_severity,
727 "Secret comparison using non-constant-time operation; vulnerable to timing attacks",
728 location,
729 String::new(),
730 vec![line.trim().to_string()],
731 None,
732 ));
733 }
734 }
735 }
736
737 findings
738 }
739}
740
741pub struct WeakCipherRule {
746 metadata: RuleMetadata,
747}
748
749impl WeakCipherRule {
750 pub fn new() -> Self {
751 Self {
752 metadata: RuleMetadata {
753 id: "RUSTCOLA045".to_string(),
754 name: "weak-cipher-usage".to_string(),
755 short_description: "Weak or deprecated cipher algorithm".to_string(),
756 full_description: "Detects use of cryptographically broken or deprecated ciphers including DES, 3DES, RC4, RC2, and Blowfish. These algorithms have known vulnerabilities and should not be used for security-sensitive operations. Use modern algorithms like AES-256-GCM or ChaCha20-Poly1305 instead.".to_string(),
757 help_uri: Some("https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/09-Testing_for_Weak_Cryptography/04-Testing_for_Weak_Encryption".to_string()),
758 default_severity: Severity::High,
759 origin: RuleOrigin::BuiltIn,
760 cwe_ids: Vec::new(),
761 fix_suggestion: None,
762 exploitability: Exploitability::default(),
763 },
764 }
765 }
766
767 fn contains_weak_cipher(line: &str) -> bool {
768 let lowered = line.to_lowercase();
769
770 if lowered.trim_start().starts_with("//") {
771 return false;
772 }
773
774 let weak_patterns = [
775 "::des::",
776 "::des<",
777 " des::",
778 "<des>",
779 "cipher::des",
780 "block_modes::des",
781 "des_ede3",
782 "tripledes",
783 "::tdes::",
784 "::tdes<",
785 "tdesede",
786 "::rc4::",
787 "::rc4<",
788 " rc4::",
789 "<rc4>",
790 "cipher::rc4",
791 "stream_cipher::rc4",
792 "::rc2::",
793 "::rc2<",
794 " rc2::",
795 "<rc2>",
796 "cipher::rc2",
797 "::blowfish::",
798 "::blowfish<",
799 " blowfish::",
800 "<blowfish>",
801 "cipher::blowfish",
802 "block_modes::blowfish",
803 "::arcfour::",
804 " arcfour::",
805 "::cast5::",
806 " cast5::",
807 ];
808
809 for pattern in weak_patterns {
810 if lowered.contains(pattern) {
811 if lowered.contains("alloc") && (lowered.contains("0x") || lowered.contains("│"))
812 {
813 continue;
814 }
815 return true;
816 }
817 }
818
819 false
820 }
821}
822
823impl Rule for WeakCipherRule {
824 fn metadata(&self) -> &RuleMetadata {
825 &self.metadata
826 }
827
828 fn evaluate(
829 &self,
830 package: &MirPackage,
831 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
832 ) -> Vec<Finding> {
833 let mut findings = Vec::new();
834
835 for function in &package.functions {
836 if function.name.contains("WeakCipherRule")
837 || function.name.contains("contains_weak_cipher")
838 {
839 continue;
840 }
841
842 for line in &function.body {
843 if Self::contains_weak_cipher(line) {
844 findings.push(Finding {
845 rule_id: self.metadata.id.clone(),
846 rule_name: self.metadata.name.clone(),
847 severity: self.metadata.default_severity,
848 message: format!(
849 "Weak or deprecated cipher algorithm detected in `{}`",
850 function.name
851 ),
852 function: function.name.clone(),
853 function_signature: function.signature.clone(),
854 evidence: vec![line.trim().to_string()],
855 span: function.span.clone(),
856 confidence: Confidence::Medium,
857 cwe_ids: Vec::new(),
858 fix_suggestion: None,
859 code_snippet: None,
860 exploitability: Exploitability::default(),
861 exploitability_score: Exploitability::default().score(),
862 ..Default::default()
863 });
864 }
865 }
866 }
867
868 findings
869 }
870}
871
872pub struct PredictableRandomnessRule {
877 metadata: RuleMetadata,
878}
879
880impl PredictableRandomnessRule {
881 pub fn new() -> Self {
882 Self {
883 metadata: RuleMetadata {
884 id: "RUSTCOLA046".to_string(),
885 name: "predictable-randomness".to_string(),
886 short_description: "Predictable random number generation".to_string(),
887 full_description: "Detects RNG initialization using constant or hardcoded seeds. Predictable randomness is a critical security flaw in cryptographic operations, session token generation, and nonce creation. Use cryptographically secure random sources like OsRng, ThreadRng, or properly seeded RNGs from entropy sources.".to_string(),
888 help_uri: Some("https://owasp.org/www-community/vulnerabilities/Insecure_Randomness".to_string()),
889 default_severity: Severity::High,
890 origin: RuleOrigin::BuiltIn,
891 cwe_ids: Vec::new(),
892 fix_suggestion: None,
893 exploitability: Exploitability::default(),
894 },
895 }
896 }
897
898 fn is_predictable_seed(line: &str) -> bool {
899 let lowered = line.to_lowercase();
900
901 if lowered.trim_start().starts_with("//") {
902 return false;
903 }
904 if lowered.contains("alloc") && (lowered.contains("0x") || lowered.contains("│")) {
905 return false;
906 }
907
908 if lowered.contains("seed_from_u64") {
909 if lowered.contains("const") && (lowered.contains("_u64") || lowered.contains("_i64")) {
910 return true;
911 }
912 }
913
914 if lowered.contains("from_seed") {
915 if lowered.contains("const") && lowered.contains("[") {
916 return true;
917 }
918 }
919
920 let seedable_rngs = [
921 "stdrng::seed_from_u64",
922 "chacharng::seed_from_u64",
923 "chacha8rng::seed_from_u64",
924 "chacha12rng::seed_from_u64",
925 "chacha20rng::seed_from_u64",
926 "isaacrng::seed_from_u64",
927 "isaac64rng::seed_from_u64",
928 "smallrng::seed_from_u64",
929 ];
930
931 for rng in seedable_rngs {
932 if lowered.contains(rng) && lowered.contains("const") {
933 return true;
934 }
935 }
936
937 false
938 }
939}
940
941impl Rule for PredictableRandomnessRule {
942 fn metadata(&self) -> &RuleMetadata {
943 &self.metadata
944 }
945
946 fn evaluate(
947 &self,
948 package: &MirPackage,
949 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
950 ) -> Vec<Finding> {
951 let mut findings = Vec::new();
952
953 for function in &package.functions {
954 if function.name.contains("PredictableRandomnessRule")
955 || function.name.contains("is_predictable_seed")
956 {
957 continue;
958 }
959
960 for line in &function.body {
961 if Self::is_predictable_seed(line) {
962 findings.push(Finding {
963 rule_id: self.metadata.id.clone(),
964 rule_name: self.metadata.name.clone(),
965 severity: self.metadata.default_severity,
966 message: format!("Predictable RNG seed detected in `{}`", function.name),
967 function: function.name.clone(),
968 function_signature: function.signature.clone(),
969 evidence: vec![line.trim().to_string()],
970 span: function.span.clone(),
971 confidence: Confidence::Medium,
972 cwe_ids: Vec::new(),
973 fix_suggestion: None,
974 code_snippet: None,
975 exploitability: Exploitability::default(),
976 exploitability_score: Exploitability::default().score(),
977 ..Default::default()
978 });
979 }
980 }
981 }
982
983 findings
984 }
985}
986
987pub struct ModuloBiasRandomRule {
992 metadata: RuleMetadata,
993}
994
995impl ModuloBiasRandomRule {
996 pub fn new() -> Self {
997 Self {
998 metadata: RuleMetadata {
999 id: "RUSTCOLA011".to_string(),
1000 name: "modulo-bias-random".to_string(),
1001 short_description: "Modulo bias in random number generation".to_string(),
1002 full_description: "Detects patterns where random numbers are reduced using modulo (%), which can introduce statistical bias. For cryptographic or security contexts, use proper bounded range generation methods like gen_range().".to_string(),
1003 help_uri: None,
1004 default_severity: Severity::Medium,
1005 origin: RuleOrigin::BuiltIn,
1006 cwe_ids: Vec::new(),
1007 fix_suggestion: None,
1008 exploitability: Exploitability::default(),
1009 },
1010 }
1011 }
1012}
1013
1014impl Rule for ModuloBiasRandomRule {
1015 fn metadata(&self) -> &RuleMetadata {
1016 &self.metadata
1017 }
1018
1019 fn evaluate(
1020 &self,
1021 package: &MirPackage,
1022 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1023 ) -> Vec<Finding> {
1024 let mut findings = Vec::new();
1025
1026 for function in &package.functions {
1027 if function.name.contains("ModuloBiasRandomRule") {
1029 continue;
1030 }
1031
1032 let has_random = function.body.iter().any(|line| {
1034 let lower = line.to_lowercase();
1035 lower.contains("::random()")
1036 || lower.contains(".next_u32()")
1037 || lower.contains(".next_u64()")
1038 || lower.contains(".gen::<")
1039 || lower.contains("getrandom")
1040 });
1041
1042 if !has_random {
1043 continue;
1044 }
1045
1046 let modulo_evidence: Vec<String> = function
1048 .body
1049 .iter()
1050 .filter(|line| line.contains("Rem(") || line.contains(" % "))
1051 .map(|line| line.trim().to_string())
1052 .collect();
1053
1054 if !modulo_evidence.is_empty() {
1055 findings.push(Finding {
1056 rule_id: self.metadata.id.clone(),
1057 rule_name: self.metadata.name.clone(),
1058 severity: self.metadata.default_severity,
1059 message: format!(
1060 "Potential modulo bias in random number generation in `{}`",
1061 function.name
1062 ),
1063 function: function.name.clone(),
1064 function_signature: function.signature.clone(),
1065 evidence: modulo_evidence,
1066 span: function.span.clone(),
1067 confidence: Confidence::Medium,
1068 cwe_ids: Vec::new(),
1069 fix_suggestion: None,
1070 code_snippet: None,
1071 exploitability: Exploitability::default(),
1072 exploitability_score: Exploitability::default().score(),
1073 ..Default::default()
1074 });
1075 }
1076 }
1077
1078 findings
1079 }
1080}
1081
1082pub fn register_crypto_rules(engine: &mut crate::RuleEngine) {
1084 engine.register_rule(Box::new(InsecureMd5Rule::new()));
1085 engine.register_rule(Box::new(InsecureSha1Rule::new()));
1086 engine.register_rule(Box::new(WeakHashingExtendedRule::new()));
1087 engine.register_rule(Box::new(HardcodedCryptoKeyRule::new()));
1088 engine.register_rule(Box::new(TimingAttackRule::new()));
1089 engine.register_rule(Box::new(WeakCipherRule::new()));
1090 engine.register_rule(Box::new(PredictableRandomnessRule::new()));
1091 engine.register_rule(Box::new(ModuloBiasRandomRule::new()));
1092}