1use crate::detect_content_length_allocations;
14use crate::{
15 Confidence, Exploitability, Finding, MirFunction, MirPackage, Rule, RuleMetadata, RuleOrigin,
16 Severity,
17};
18use std::collections::HashSet;
19
20pub struct NonHttpsUrlRule {
26 metadata: RuleMetadata,
27}
28
29impl NonHttpsUrlRule {
30 pub fn new() -> Self {
31 Self {
32 metadata: RuleMetadata {
33 id: "RUSTCOLA011".to_string(),
34 name: "non-https-url".to_string(),
35 short_description: "HTTP URL usage".to_string(),
36 full_description: "Flags string literals using http:// URLs in networking code where HTTPS is expected.".to_string(),
37 help_uri: None,
38 default_severity: Severity::Medium,
39 origin: RuleOrigin::BuiltIn,
40 cwe_ids: Vec::new(),
41 fix_suggestion: None,
42 exploitability: Exploitability::default(),
43 },
44 }
45 }
46}
47
48impl Rule for NonHttpsUrlRule {
49 fn metadata(&self) -> &RuleMetadata {
50 &self.metadata
51 }
52
53 fn evaluate(
54 &self,
55 package: &MirPackage,
56 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
57 ) -> Vec<Finding> {
58 if package.crate_name == "mir-extractor" {
59 return Vec::new();
60 }
61
62 let mut findings = Vec::new();
63 let patterns = ["\"http://", "http://"];
64
65 for function in &package.functions {
66 let evidence: Vec<String> = function
67 .body
68 .iter()
69 .filter(|line| patterns.iter().any(|p| line.contains(p)))
70 .map(|line| line.trim().to_string())
71 .collect();
72
73 if evidence.is_empty() {
74 continue;
75 }
76
77 findings.push(Finding {
78 rule_id: self.metadata.id.clone(),
79 rule_name: self.metadata.name.clone(),
80 severity: self.metadata.default_severity,
81 message: format!("HTTP URL literal detected in `{}`", function.name),
82 function: function.name.clone(),
83 function_signature: function.signature.clone(),
84 evidence,
85 span: function.span.clone(),
86 confidence: Confidence::Medium,
87 cwe_ids: Vec::new(),
88 fix_suggestion: None,
89 code_snippet: None,
90 exploitability: Exploitability::default(),
91 exploitability_score: Exploitability::default().score(),
92 ..Default::default()
93 });
94 }
95
96 findings
97 }
98}
99
100const DANGER_ACCEPT_INVALID_CERTS_SYMBOL: &str = concat!("danger", "_accept", "_invalid", "_certs");
105const DANGER_ACCEPT_INVALID_HOSTNAMES_SYMBOL: &str =
106 concat!("danger", "_accept", "_invalid", "_hostnames");
107
108fn line_disables_tls_verification(line: &str) -> bool {
109 let trimmed = line.trim();
110 if trimmed.is_empty() {
111 return false;
112 }
113
114 let lower = trimmed.to_lowercase();
115
116 if lower.contains(DANGER_ACCEPT_INVALID_CERTS_SYMBOL) && lower.contains("true") {
117 return true;
118 }
119
120 if lower.contains(DANGER_ACCEPT_INVALID_HOSTNAMES_SYMBOL) && lower.contains("true") {
121 return true;
122 }
123
124 let touches_dangerous_client = lower.contains("dangerous::dangerousclientconfig");
125 let sets_custom_verifier = lower.contains("set_certificate_verifier");
126 let sets_custom_resolver = lower.contains("set_certificate_resolver");
127
128 if touches_dangerous_client && (sets_custom_verifier || sets_custom_resolver) {
129 return true;
130 }
131
132 if (sets_custom_verifier || sets_custom_resolver)
133 && (lower.contains("noverifier")
134 || lower.contains("nocertificateverification")
135 || lower.contains("no_certificate_verifier"))
136 {
137 return true;
138 }
139
140 if lower.contains("dangerous()") && (sets_custom_verifier || sets_custom_resolver) {
141 return true;
142 }
143
144 false
145}
146
147pub struct DangerAcceptInvalidCertRule {
149 metadata: RuleMetadata,
150}
151
152impl DangerAcceptInvalidCertRule {
153 pub fn new() -> Self {
154 Self {
155 metadata: RuleMetadata {
156 id: "RUSTCOLA012".to_string(),
157 name: "danger-accept-invalid-certs".to_string(),
158 short_description: "TLS certificate validation disabled".to_string(),
159 full_description: "Detects calls enabling reqwest's danger_accept_invalid_certs(true), which disables certificate validation.".to_string(),
160 help_uri: None,
161 default_severity: Severity::High,
162 origin: RuleOrigin::BuiltIn,
163 cwe_ids: Vec::new(),
164 fix_suggestion: None,
165 exploitability: Exploitability::default(),
166 },
167 }
168 }
169}
170
171impl Rule for DangerAcceptInvalidCertRule {
172 fn metadata(&self) -> &RuleMetadata {
173 &self.metadata
174 }
175
176 fn evaluate(
177 &self,
178 package: &MirPackage,
179 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
180 ) -> Vec<Finding> {
181 if package.crate_name == "mir-extractor" {
182 return Vec::new();
183 }
184
185 let mut findings = Vec::new();
186
187 for function in &package.functions {
188 let mut lines = Vec::new();
189 let mut seen = HashSet::new();
190
191 for raw_line in &function.body {
192 if line_disables_tls_verification(raw_line) {
193 let trimmed = raw_line.trim().to_string();
194 if seen.insert(trimmed.clone()) {
195 lines.push(trimmed);
196 }
197 }
198 }
199
200 if lines.is_empty() {
201 continue;
202 }
203
204 findings.push(Finding {
205 rule_id: self.metadata.id.clone(),
206 rule_name: self.metadata.name.clone(),
207 severity: self.metadata.default_severity,
208 message: format!(
209 "TLS validation disabled via danger_accept_invalid_* in `{}`",
210 function.name
211 ),
212 function: function.name.clone(),
213 function_signature: function.signature.clone(),
214 evidence: lines,
215 span: function.span.clone(),
216 confidence: Confidence::Medium,
217 cwe_ids: Vec::new(),
218 fix_suggestion: None,
219 code_snippet: None,
220 exploitability: Exploitability::default(),
221 exploitability_score: Exploitability::default().score(),
222 ..Default::default()
223 });
224 }
225
226 findings
227 }
228}
229
230struct OpensslVerifyNoneInvocation {
235 call_line: String,
236 supporting_lines: Vec<String>,
237}
238
239fn detect_openssl_verify_none(function: &MirFunction) -> Vec<OpensslVerifyNoneInvocation> {
240 let mut invocations = Vec::new();
241 let mut processed_indices = std::collections::HashSet::new();
242
243 for (i, line) in function.body.iter().enumerate() {
244 if processed_indices.contains(&i) {
245 continue;
246 }
247 let lower = line.to_lowercase();
248
249 if lower.contains("set_verify") && (lower.contains("none") || lower.contains("empty()")) {
251 let mut supporting = Vec::new();
252 for j in i.saturating_sub(3)..=(i + 3).min(function.body.len() - 1) {
254 if j != i {
255 supporting.push(function.body[j].trim().to_string());
256 }
257 }
258 processed_indices.insert(i);
259 invocations.push(OpensslVerifyNoneInvocation {
260 call_line: line.trim().to_string(),
261 supporting_lines: supporting,
262 });
263 continue;
264 }
265
266 if lower.contains("sslverifymode::empty()") || lower.contains("sslverifymode::none") {
268 for j in (i + 1)..function.body.len().min(i + 5) {
270 if function.body[j].to_lowercase().contains("set_verify") {
271 processed_indices.insert(i);
273 processed_indices.insert(j);
274 invocations.push(OpensslVerifyNoneInvocation {
275 call_line: function.body[j].trim().to_string(),
276 supporting_lines: vec![line.trim().to_string()],
277 });
278 break;
279 }
280 }
281 }
282 }
283
284 invocations
285}
286
287pub struct OpensslVerifyNoneRule {
289 metadata: RuleMetadata,
290}
291
292impl OpensslVerifyNoneRule {
293 pub fn new() -> Self {
294 Self {
295 metadata: RuleMetadata {
296 id: "RUSTCOLA013".to_string(),
297 name: "openssl-verify-none".to_string(),
298 short_description: "SslContext configured with VerifyNone".to_string(),
299 full_description: "Detects OpenSSL configurations that disable certificate verification (VerifyNone).".to_string(),
300 help_uri: None,
301 default_severity: Severity::High,
302 origin: RuleOrigin::BuiltIn,
303 cwe_ids: Vec::new(),
304 fix_suggestion: None,
305 exploitability: Exploitability::default(),
306 },
307 }
308 }
309}
310
311impl Rule for OpensslVerifyNoneRule {
312 fn metadata(&self) -> &RuleMetadata {
313 &self.metadata
314 }
315
316 fn evaluate(
317 &self,
318 package: &MirPackage,
319 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
320 ) -> Vec<Finding> {
321 if package.crate_name == "mir-extractor" {
323 return Vec::new();
324 }
325
326 let mut findings = Vec::new();
327
328 for function in &package.functions {
329 let invocations = detect_openssl_verify_none(function);
330 if invocations.is_empty() {
331 continue;
332 }
333
334 for invocation in invocations {
335 let mut evidence = vec![invocation.call_line.clone()];
336 for line in invocation.supporting_lines {
337 if !evidence.contains(&line) {
338 evidence.push(line);
339 }
340 }
341
342 findings.push(Finding {
343 rule_id: self.metadata.id.clone(),
344 rule_name: self.metadata.name.clone(),
345 severity: self.metadata.default_severity,
346 message: format!(
347 "OpenSSL certificate verification disabled in `{}`",
348 function.name
349 ),
350 function: function.name.clone(),
351 function_signature: function.signature.clone(),
352 evidence,
353 span: function.span.clone(),
354 confidence: Confidence::Medium,
355 cwe_ids: Vec::new(),
356 fix_suggestion: None,
357 code_snippet: None,
358 exploitability: Exploitability::default(),
359 exploitability_score: Exploitability::default().score(),
360 ..Default::default()
361 });
362 }
363 }
364
365 findings
366 }
367}
368
369pub struct CookieSecureAttributeRule {
375 metadata: RuleMetadata,
376}
377
378impl CookieSecureAttributeRule {
379 pub fn new() -> Self {
380 Self {
381 metadata: RuleMetadata {
382 id: "RUSTCOLA042".to_string(),
383 name: "cookie-secure-attribute".to_string(),
384 short_description: "Cookie missing Secure attribute".to_string(),
385 full_description: "Detects cookies set without the Secure attribute, which allows transmission over unencrypted connections.".to_string(),
386 help_uri: None,
387 default_severity: Severity::Medium,
388 origin: RuleOrigin::BuiltIn,
389 cwe_ids: Vec::new(),
390 fix_suggestion: None,
391 exploitability: Exploitability::default(),
392 },
393 }
394 }
395}
396
397impl Rule for CookieSecureAttributeRule {
398 fn metadata(&self) -> &RuleMetadata {
399 &self.metadata
400 }
401
402 fn evaluate(
403 &self,
404 package: &MirPackage,
405 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
406 ) -> Vec<Finding> {
407 let mut findings = Vec::new();
408
409 for function in &package.functions {
410 let cookie_lines: Vec<&String> = function
412 .body
413 .iter()
414 .filter(|line| {
415 let lower = line.to_lowercase();
416 lower.contains("cookie") && lower.contains("new")
417 })
418 .collect();
419
420 if cookie_lines.is_empty() {
421 continue;
422 }
423
424 let has_secure = function.body.iter().any(|line| {
426 let lower = line.to_lowercase();
427 lower.contains("secure(true)") || lower.contains("set_secure")
428 });
429
430 if !has_secure {
431 findings.push(Finding {
432 rule_id: self.metadata.id.clone(),
433 rule_name: self.metadata.name.clone(),
434 severity: self.metadata.default_severity,
435 message: format!(
436 "Cookie created without Secure attribute in `{}`",
437 function.name
438 ),
439 function: function.name.clone(),
440 function_signature: function.signature.clone(),
441 evidence: cookie_lines.iter().map(|s| s.trim().to_string()).collect(),
442 span: function.span.clone(),
443 confidence: Confidence::Medium,
444 cwe_ids: Vec::new(),
445 fix_suggestion: None,
446 code_snippet: None,
447 exploitability: Exploitability::default(),
448 exploitability_score: Exploitability::default().score(),
449 ..Default::default()
450 });
451 }
452 }
453
454 findings
455 }
456}
457
458pub struct CorsWildcardRule {
464 metadata: RuleMetadata,
465}
466
467impl CorsWildcardRule {
468 pub fn new() -> Self {
469 Self {
470 metadata: RuleMetadata {
471 id: "RUSTCOLA043".to_string(),
472 name: "cors-wildcard".to_string(),
473 short_description: "CORS wildcard origin configured".to_string(),
474 full_description: "Detects CORS configurations that allow any origin (*), which can enable cross-site request attacks.".to_string(),
475 help_uri: None,
476 default_severity: Severity::Medium,
477 origin: RuleOrigin::BuiltIn,
478 cwe_ids: Vec::new(),
479 fix_suggestion: None,
480 exploitability: Exploitability::default(),
481 },
482 }
483 }
484}
485
486impl Rule for CorsWildcardRule {
487 fn metadata(&self) -> &RuleMetadata {
488 &self.metadata
489 }
490
491 fn evaluate(
492 &self,
493 package: &MirPackage,
494 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
495 ) -> Vec<Finding> {
496 let mut findings = Vec::new();
497
498 for function in &package.functions {
499 let evidence: Vec<String> = function
500 .body
501 .iter()
502 .filter(|line| {
503 let lower = line.to_lowercase();
504 (lower.contains("cors") || lower.contains("access-control-allow-origin"))
506 && (lower.contains("\"*\"")
507 || lower.contains("any()")
508 || lower.contains("permissive()"))
509 })
510 .map(|s| s.trim().to_string())
511 .collect();
512
513 if !evidence.is_empty() {
514 findings.push(Finding {
515 rule_id: self.metadata.id.clone(),
516 rule_name: self.metadata.name.clone(),
517 severity: self.metadata.default_severity,
518 message: format!("CORS wildcard origin in `{}`", function.name),
519 function: function.name.clone(),
520 function_signature: function.signature.clone(),
521 evidence,
522 span: function.span.clone(),
523 confidence: Confidence::Medium,
524 cwe_ids: Vec::new(),
525 fix_suggestion: None,
526 code_snippet: None,
527 exploitability: Exploitability::default(),
528 exploitability_score: Exploitability::default().score(),
529 ..Default::default()
530 });
531 }
532 }
533
534 findings
535 }
536}
537
538pub struct ConnectionStringPasswordRule {
544 metadata: RuleMetadata,
545}
546
547impl ConnectionStringPasswordRule {
548 pub fn new() -> Self {
549 Self {
550 metadata: RuleMetadata {
551 id: "RUSTCOLA060".to_string(),
552 name: "connection-string-password".to_string(),
553 short_description: "Password in connection string".to_string(),
554 full_description: "Detects hardcoded passwords in database connection strings."
555 .to_string(),
556 help_uri: None,
557 default_severity: Severity::High,
558 origin: RuleOrigin::BuiltIn,
559 cwe_ids: Vec::new(),
560 fix_suggestion: None,
561 exploitability: Exploitability::default(),
562 },
563 }
564 }
565}
566
567impl Rule for ConnectionStringPasswordRule {
568 fn metadata(&self) -> &RuleMetadata {
569 &self.metadata
570 }
571
572 fn evaluate(
573 &self,
574 package: &MirPackage,
575 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
576 ) -> Vec<Finding> {
577 let mut findings = Vec::new();
578
579 for function in &package.functions {
580 let evidence: Vec<String> = function
581 .body
582 .iter()
583 .filter(|line| {
584 let lower = line.to_lowercase();
585 (lower.contains("password=") || lower.contains("pwd="))
587 && (lower.contains("postgres")
588 || lower.contains("mysql")
589 || lower.contains("mongodb")
590 || lower.contains("redis")
591 || lower.contains("connection")
592 || lower.contains("database"))
593 })
594 .map(|s| s.trim().to_string())
595 .collect();
596
597 if !evidence.is_empty() {
598 findings.push(Finding {
599 rule_id: self.metadata.id.clone(),
600 rule_name: self.metadata.name.clone(),
601 severity: self.metadata.default_severity,
602 message: format!(
603 "Hardcoded password in connection string in `{}`",
604 function.name
605 ),
606 function: function.name.clone(),
607 function_signature: function.signature.clone(),
608 evidence,
609 span: function.span.clone(),
610 confidence: Confidence::Medium,
611 cwe_ids: Vec::new(),
612 fix_suggestion: None,
613 code_snippet: None,
614 exploitability: Exploitability::default(),
615 exploitability_score: Exploitability::default().score(),
616 ..Default::default()
617 });
618 }
619 }
620
621 findings
622 }
623}
624
625pub struct PasswordFieldMaskingRule {
631 metadata: RuleMetadata,
632}
633
634impl PasswordFieldMaskingRule {
635 pub fn new() -> Self {
636 Self {
637 metadata: RuleMetadata {
638 id: "RUSTCOLA061".to_string(),
639 name: "password-field-masking".to_string(),
640 short_description: "Password field not masked".to_string(),
641 full_description: "Detects password fields in Debug or Display implementations that may leak sensitive data.".to_string(),
642 help_uri: None,
643 default_severity: Severity::Medium,
644 origin: RuleOrigin::BuiltIn,
645 cwe_ids: Vec::new(),
646 fix_suggestion: None,
647 exploitability: Exploitability::default(),
648 },
649 }
650 }
651}
652
653impl Rule for PasswordFieldMaskingRule {
654 fn metadata(&self) -> &RuleMetadata {
655 &self.metadata
656 }
657
658 fn evaluate(
659 &self,
660 package: &MirPackage,
661 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
662 ) -> Vec<Finding> {
663 let mut findings = Vec::new();
664
665 for function in &package.functions {
666 let is_debug_impl = function.name.contains("fmt")
668 && (function.signature.contains("Debug") || function.signature.contains("Display"));
669
670 if !is_debug_impl {
671 continue;
672 }
673
674 let has_password_field = function.body.iter().any(|line| {
675 let lower = line.to_lowercase();
676 lower.contains("password") || lower.contains("secret") || lower.contains("token")
677 });
678
679 if has_password_field {
680 let evidence: Vec<String> = function
681 .body
682 .iter()
683 .filter(|line| {
684 let lower = line.to_lowercase();
685 lower.contains("password")
686 || lower.contains("secret")
687 || lower.contains("token")
688 })
689 .map(|s| s.trim().to_string())
690 .collect();
691
692 findings.push(Finding {
693 rule_id: self.metadata.id.clone(),
694 rule_name: self.metadata.name.clone(),
695 severity: self.metadata.default_severity,
696 message: format!(
697 "Password field exposed in Debug/Display in `{}`",
698 function.name
699 ),
700 function: function.name.clone(),
701 function_signature: function.signature.clone(),
702 evidence,
703 span: function.span.clone(),
704 confidence: Confidence::Medium,
705 cwe_ids: Vec::new(),
706 fix_suggestion: None,
707 code_snippet: None,
708 exploitability: Exploitability::default(),
709 exploitability_score: Exploitability::default().score(),
710 ..Default::default()
711 });
712 }
713 }
714
715 findings
716 }
717}
718
719pub struct CleartextLoggingRule {
725 metadata: RuleMetadata,
726}
727
728impl CleartextLoggingRule {
729 pub fn new() -> Self {
730 Self {
731 metadata: RuleMetadata {
732 id: "RUSTCOLA075".to_string(),
733 name: "cleartext-logging".to_string(),
734 short_description: "Sensitive data in logs".to_string(),
735 full_description:
736 "Detects logging of sensitive data (passwords, tokens, keys) without masking."
737 .to_string(),
738 help_uri: None,
739 default_severity: Severity::Medium,
740 origin: RuleOrigin::BuiltIn,
741 cwe_ids: Vec::new(),
742 fix_suggestion: None,
743 exploitability: Exploitability::default(),
744 },
745 }
746 }
747}
748
749impl Rule for CleartextLoggingRule {
750 fn metadata(&self) -> &RuleMetadata {
751 &self.metadata
752 }
753
754 fn evaluate(
755 &self,
756 package: &MirPackage,
757 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
758 ) -> Vec<Finding> {
759 let mut findings = Vec::new();
760
761 for function in &package.functions {
762 let evidence: Vec<String> = function
763 .body
764 .iter()
765 .filter(|line| {
766 let lower = line.to_lowercase();
767 let has_log = lower.contains("log::")
769 || lower.contains("tracing::")
770 || lower.contains("println!")
771 || lower.contains("eprintln!");
772 let has_sensitive = lower.contains("password")
773 || lower.contains("secret")
774 || lower.contains("token")
775 || lower.contains("api_key");
776 has_log && has_sensitive
777 })
778 .map(|s| s.trim().to_string())
779 .collect();
780
781 if !evidence.is_empty() {
782 findings.push(Finding {
783 rule_id: self.metadata.id.clone(),
784 rule_name: self.metadata.name.clone(),
785 severity: self.metadata.default_severity,
786 message: format!("Sensitive data logged in cleartext in `{}`", function.name),
787 function: function.name.clone(),
788 function_signature: function.signature.clone(),
789 evidence,
790 span: function.span.clone(),
791 confidence: Confidence::Medium,
792 cwe_ids: Vec::new(),
793 fix_suggestion: None,
794 code_snippet: None,
795 exploitability: Exploitability::default(),
796 exploitability_score: Exploitability::default().score(),
797 ..Default::default()
798 });
799 }
800 }
801
802 findings
803 }
804}
805
806pub struct TlsVerificationDisabledRule {
820 metadata: RuleMetadata,
821}
822
823impl TlsVerificationDisabledRule {
824 pub fn new() -> Self {
825 Self {
826 metadata: RuleMetadata {
827 id: "RUSTCOLA084".to_string(),
828 name: "tls-verification-disabled".to_string(),
829 short_description: "TLS certificate verification disabled".to_string(),
830 full_description: "Detects disabled TLS certificate verification in HTTP/TLS \
831 client libraries including native-tls, rustls, reqwest, and hyper-tls. \
832 Disabling certificate verification allows man-in-the-middle attacks. \
833 Only disable in controlled environments (e.g., testing with self-signed certs) \
834 and never in production code."
835 .to_string(),
836 default_severity: Severity::High,
837 origin: RuleOrigin::BuiltIn,
838 cwe_ids: Vec::new(),
839 fix_suggestion: None,
840 help_uri: None,
841 exploitability: Exploitability::default(),
842 },
843 }
844 }
845}
846
847impl Rule for TlsVerificationDisabledRule {
848 fn metadata(&self) -> &RuleMetadata {
849 &self.metadata
850 }
851
852 fn evaluate(
853 &self,
854 package: &MirPackage,
855 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
856 ) -> Vec<Finding> {
857 let mut findings = Vec::new();
858
859 for function in &package.functions {
860 if function.signature.contains("#[test]")
862 || function.name.contains("test")
863 || function.signature.contains("#[cfg(test)]")
864 {
865 continue;
866 }
867
868 let mut found_dangers: Vec<(String, String)> = Vec::new();
869
870 for line in &function.body {
871 let trimmed = line.trim();
872
873 if trimmed.contains("danger_accept_invalid_certs") && trimmed.contains("const true")
876 {
877 let library = if trimmed.contains("native_tls")
878 || trimmed.contains("TlsConnectorBuilder")
879 {
880 "native-tls"
881 } else if trimmed.contains("reqwest") || trimmed.contains("ClientBuilder") {
882 "reqwest"
883 } else {
884 "TLS library"
885 };
886 found_dangers.push((
887 format!(
888 "{}: danger_accept_invalid_certs(true) disables certificate validation",
889 library
890 ),
891 trimmed.to_string(),
892 ));
893 }
894
895 if trimmed.contains("danger_accept_invalid_hostnames")
897 && trimmed.contains("const true")
898 {
899 let library = if trimmed.contains("native_tls")
900 || trimmed.contains("TlsConnectorBuilder")
901 {
902 "native-tls"
903 } else if trimmed.contains("reqwest") || trimmed.contains("ClientBuilder") {
904 "reqwest"
905 } else {
906 "TLS library"
907 };
908 found_dangers.push((
909 format!("{}: danger_accept_invalid_hostnames(true) disables hostname verification", library),
910 trimmed.to_string(),
911 ));
912 }
913
914 if (trimmed.contains(">::dangerous(") || trimmed.contains("::dangerous(move"))
917 && (trimmed.contains("rustls")
918 || trimmed.contains("ConfigBuilder")
919 || trimmed.contains("WantsVerifier"))
920 {
921 found_dangers.push((
922 "rustls: .dangerous() enables unsafe TLS configuration".to_string(),
923 trimmed.to_string(),
924 ));
925 }
926
927 if trimmed.contains("DangerousClientConfigBuilder")
929 && trimmed.contains("with_custom_certificate_verifier")
930 {
931 found_dangers.push((
932 "rustls: custom certificate verifier may bypass validation".to_string(),
933 trimmed.to_string(),
934 ));
935 }
936
937 if trimmed.contains("ServerCertVerified::assertion()") {
939 found_dangers.push((
940 "rustls: ServerCertVerified::assertion() unconditionally accepts certificates".to_string(),
941 trimmed.to_string(),
942 ));
943 }
944
945 if (trimmed.contains("set_verify") && trimmed.contains("NONE"))
948 || trimmed.contains("SSL_VERIFY_NONE")
949 {
950 found_dangers.push((
951 "OpenSSL: SSL_VERIFY_NONE disables certificate verification".to_string(),
952 trimmed.to_string(),
953 ));
954 }
955
956 if trimmed.contains("danger")
959 && trimmed.contains("const true")
960 && !found_dangers.iter().any(|(_, e)| e == trimmed)
961 {
962 found_dangers.push((
963 "TLS danger method called with true - verification may be disabled"
964 .to_string(),
965 trimmed.to_string(),
966 ));
967 }
968 }
969
970 for (message, evidence) in found_dangers {
972 findings.push(Finding {
973 rule_id: self.metadata.id.clone(),
974 rule_name: self.metadata.name.clone(),
975 severity: self.metadata.default_severity,
976 message: format!(
977 "{}. This allows man-in-the-middle attacks. \
978 Only disable in controlled test environments, never in production.",
979 message
980 ),
981 function: function.name.clone(),
982 function_signature: function.signature.clone(),
983 evidence: vec![evidence],
984 span: function.span.clone(),
985 confidence: Confidence::Medium,
986 cwe_ids: Vec::new(),
987 fix_suggestion: None,
988 code_snippet: None,
989 exploitability: Exploitability::default(),
990 exploitability_score: Exploitability::default().score(),
991 ..Default::default()
992 });
993 }
994 }
995
996 findings
997 }
998}
999
1000pub struct AwsS3UnscopedAccessRule {
1008 metadata: RuleMetadata,
1009}
1010
1011impl AwsS3UnscopedAccessRule {
1012 pub fn new() -> Self {
1013 Self {
1014 metadata: RuleMetadata {
1015 id: "RUSTCOLA085".to_string(),
1016 name: "aws-s3-unscoped-access".to_string(),
1017 short_description: "AWS S3 operation with untrusted bucket/key/prefix".to_string(),
1018 full_description: "Detects AWS S3 SDK operations (list_objects, put_object, \
1019 delete_object, get_object, etc.) where bucket names, keys, or prefixes \
1020 come from untrusted sources (environment variables, CLI arguments) without \
1021 validation. Attackers can exploit this to access, modify, or delete arbitrary \
1022 S3 objects. Use allowlists, starts_with validation, or path sanitization."
1023 .to_string(),
1024 default_severity: Severity::High,
1025 origin: RuleOrigin::BuiltIn,
1026 cwe_ids: Vec::new(),
1027 fix_suggestion: None,
1028 help_uri: None,
1029 exploitability: Exploitability::default(),
1030 },
1031 }
1032 }
1033
1034 fn has_validation(&self, lines: &[&str], s3_call_idx: usize) -> bool {
1036 for i in 0..s3_call_idx {
1038 let trimmed = lines[i].trim();
1039
1040 if trimmed.contains("::contains(") && !trimmed.contains("str>::contains") {
1042 return true;
1043 }
1044
1045 if trimmed.contains("starts_with") && !trimmed.contains("trim_start") {
1047 return true;
1048 }
1049
1050 if trimmed.contains("replace") && trimmed.contains("\"..\"") {
1052 return true;
1053 }
1054
1055 if (trimmed.contains("assert!") || trimmed.contains("panic!"))
1057 && (trimmed.contains("bucket")
1058 || trimmed.contains("key")
1059 || trimmed.contains("prefix"))
1060 {
1061 return true;
1062 }
1063
1064 if trimmed.contains("filter::<") && trimmed.contains("Chars") {
1066 return true;
1067 }
1068 }
1069 false
1070 }
1071
1072 fn get_operation_severity(&self, method: &str) -> Severity {
1074 if method.contains("delete") || method.contains("Delete") {
1075 Severity::High } else if method.contains("put") || method.contains("Put") || method.contains("copy") {
1077 Severity::High } else {
1079 Severity::Medium }
1081 }
1082}
1083
1084impl Rule for AwsS3UnscopedAccessRule {
1085 fn metadata(&self) -> &RuleMetadata {
1086 &self.metadata
1087 }
1088
1089 fn evaluate(
1090 &self,
1091 package: &MirPackage,
1092 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1093 ) -> Vec<Finding> {
1094 let mut findings = Vec::new();
1095
1096 for function in &package.functions {
1097 if function.name.contains("test")
1099 || function.name.starts_with("test_")
1100 || function.signature.contains("#[test]")
1101 || function.signature.contains("#[cfg(test)]")
1102 {
1103 continue;
1104 }
1105
1106 let lines: Vec<&str> = function.body.iter().map(|s| s.as_str()).collect();
1107
1108 let mut untrusted_vars: HashSet<String> = HashSet::new();
1110
1111 for (_idx, line) in lines.iter().enumerate() {
1113 let trimmed = line.trim();
1114
1115 if trimmed.contains("Result<") && trimmed.contains("VarError") {
1118 if let Some(colon_pos) = trimmed.find(':') {
1119 let var_part = trimmed[..colon_pos]
1120 .trim()
1121 .trim_start_matches("let")
1122 .trim()
1123 .trim_start_matches("mut")
1124 .trim();
1125 if var_part.starts_with('_') {
1126 untrusted_vars.insert(var_part.to_string());
1127 }
1128 }
1129 }
1130
1131 if trimmed.contains("var::<") && trimmed.contains("const \"") {
1133 if let Some(eq_pos) = trimmed.find(" = ") {
1135 let var_part = trimmed[..eq_pos].trim();
1136 if var_part.starts_with('_') {
1137 untrusted_vars.insert(var_part.to_string());
1138 }
1139 }
1140 }
1141
1142 if trimmed.contains("env::args") || trimmed.contains("Args") {
1144 if let Some(eq_pos) = trimmed.find(" = ") {
1145 let var_part = trimmed[..eq_pos].trim();
1146 if var_part.starts_with('_') {
1147 untrusted_vars.insert(var_part.to_string());
1148 }
1149 }
1150 }
1151
1152 if trimmed.contains("VarError>::unwrap")
1155 || (trimmed.contains("unwrap")
1156 && trimmed.contains("move _")
1157 && untrusted_vars
1158 .iter()
1159 .any(|v| trimmed.contains(&format!("move {}", v))))
1160 {
1161 if let Some(eq_pos) = trimmed.find(" = ") {
1163 let dest_part = trimmed[..eq_pos].trim();
1164 if dest_part.contains(".0:") || dest_part.starts_with('_') {
1166 if dest_part.starts_with('_') {
1168 untrusted_vars.insert(dest_part.to_string());
1169 }
1170 untrusted_vars.insert(dest_part.to_string());
1172 }
1173 }
1174 }
1175
1176 if trimmed.contains("unwrap::<") && trimmed.contains("Result<std::string::String") {
1178 if let Some(eq_pos) = trimmed.find(" = ") {
1179 let var_part = trimmed[..eq_pos].trim();
1180 for tainted in untrusted_vars.clone() {
1182 if trimmed.contains(&format!("move {}", tainted))
1183 || trimmed.contains(&format!("copy {}", tainted))
1184 {
1185 untrusted_vars.insert(var_part.to_string());
1186 break;
1187 }
1188 }
1189 }
1190 }
1191
1192 if trimmed.contains("Index>::index") || trimmed.contains("[") {
1194 if let Some(eq_pos) = trimmed.find(" = ") {
1195 let var_part = trimmed[..eq_pos].trim();
1196 for tainted in untrusted_vars.clone() {
1197 if trimmed.contains(&tainted) {
1198 untrusted_vars.insert(var_part.to_string());
1199 break;
1200 }
1201 }
1202 }
1203 }
1204
1205 if trimmed.contains(" = copy ") || trimmed.contains(" = move ") {
1207 if let Some(eq_pos) = trimmed.find(" = ") {
1208 let var_part = trimmed[..eq_pos].trim();
1209 for tainted in untrusted_vars.clone() {
1210 if trimmed.contains(&format!("copy {}", tainted))
1211 || trimmed.contains(&format!("move {}", tainted))
1212 || trimmed.contains(&format!("&{}", tainted))
1213 {
1214 untrusted_vars.insert(var_part.to_string());
1215 break;
1216 }
1217 }
1218 }
1219 }
1220
1221 if trimmed.contains(" = &")
1224 && trimmed.contains("variant#")
1225 && trimmed.contains(".0:")
1226 {
1227 if let Some(eq_pos) = trimmed.find(" = ") {
1228 let var_part = trimmed[..eq_pos].trim();
1229 for tainted in untrusted_vars.clone() {
1232 if tainted.contains("variant#") && tainted.contains(".0:") {
1233 untrusted_vars.insert(var_part.to_string());
1235 break;
1236 }
1237 }
1238 }
1239 }
1240
1241 if trimmed.contains("format_argument") || trimmed.contains("Arguments::") {
1243 if let Some(eq_pos) = trimmed.find(" = ") {
1244 let var_part = trimmed[..eq_pos].trim();
1245 for tainted in untrusted_vars.clone() {
1246 if trimmed.contains(&tainted) {
1247 untrusted_vars.insert(var_part.to_string());
1248 break;
1249 }
1250 }
1251 }
1252 }
1253 }
1254
1255 let s3_methods = [
1257 "ListObjectsV2FluentBuilder::bucket",
1258 "ListObjectsV2FluentBuilder::prefix",
1259 "PutObjectFluentBuilder::bucket",
1260 "PutObjectFluentBuilder::key",
1261 "DeleteObjectFluentBuilder::bucket",
1262 "DeleteObjectFluentBuilder::key",
1263 "GetObjectFluentBuilder::bucket",
1264 "GetObjectFluentBuilder::key",
1265 "HeadObjectFluentBuilder::bucket",
1266 "HeadObjectFluentBuilder::key",
1267 "CopyObjectFluentBuilder::bucket",
1268 "CopyObjectFluentBuilder::key",
1269 ];
1270
1271 for (idx, line) in lines.iter().enumerate() {
1272 let trimmed = line.trim();
1273
1274 for method in &s3_methods {
1275 if trimmed.contains(method) {
1276 if trimmed.contains("const \"")
1278 || trimmed.contains("const safe_")
1279 || trimmed.contains("::ALLOWED_")
1280 {
1281 continue;
1282 }
1283
1284 let mut tainted_param = None;
1286 for tainted in &untrusted_vars {
1287 if trimmed.contains(&format!("move {}", tainted))
1288 || trimmed.contains(&format!("copy {}", tainted))
1289 || trimmed.contains(&format!("&{}", tainted))
1290 {
1291 tainted_param = Some(tainted.clone());
1292 break;
1293 }
1294 }
1295
1296 if let Some(_param) = tainted_param {
1297 if self.has_validation(&lines, idx) {
1299 continue; }
1301
1302 let op_type = if method.contains("bucket") {
1304 "bucket"
1305 } else if method.contains("prefix") {
1306 "prefix"
1307 } else {
1308 "key"
1309 };
1310
1311 let severity = self.get_operation_severity(method);
1312
1313 let operation = if method.contains("List") {
1314 "list_objects"
1315 } else if method.contains("Put") {
1316 "put_object"
1317 } else if method.contains("Delete") {
1318 "delete_object"
1319 } else if method.contains("Get") {
1320 "get_object"
1321 } else if method.contains("Head") {
1322 "head_object"
1323 } else if method.contains("Copy") {
1324 "copy_object"
1325 } else {
1326 "S3 operation"
1327 };
1328
1329 findings.push(Finding {
1330 rule_id: self.metadata.id.clone(),
1331 rule_name: self.metadata.name.clone(),
1332 severity,
1333 message: format!(
1334 "S3 {} receives untrusted {} parameter from environment variable or CLI argument. \
1335 Attackers can manipulate this to access, modify, or delete arbitrary S3 objects. \
1336 Use allowlist validation, starts_with scoping, or input sanitization.",
1337 operation, op_type
1338 ),
1339 function: function.name.clone(),
1340 function_signature: function.signature.clone(),
1341 evidence: vec![trimmed.to_string()],
1342 span: function.span.clone(),
1343 confidence: Confidence::Medium,
1344 cwe_ids: Vec::new(),
1345 fix_suggestion: None,
1346 code_snippet: None,
1347 exploitability: Exploitability::default(),
1348 exploitability_score: Exploitability::default().score(),
1349 ..Default::default()
1350 });
1351
1352 break; }
1354 }
1355 }
1356 }
1357 }
1358
1359 findings
1360 }
1361}
1362
1363pub struct ContentLengthAllocationRule {
1368 metadata: RuleMetadata,
1369}
1370
1371impl ContentLengthAllocationRule {
1372 pub fn new() -> Self {
1373 Self {
1374 metadata: RuleMetadata {
1375 id: "RUSTCOLA021".to_string(),
1376 name: "content-length-allocation".to_string(),
1377 short_description:
1378 "Allocations sized from untrusted Content-Length header".to_string(),
1379 full_description: "Flags allocations (`Vec::with_capacity`, `reserve*`) that trust HTTP Content-Length values without upper bounds, enabling attacker-controlled memory exhaustion. See RUSTSEC-2025-0015 for real-world examples.".to_string(),
1380 help_uri: Some("https://rustsec.org/advisories/RUSTSEC-2025-0015.html".to_string()),
1381 default_severity: Severity::High,
1382 origin: RuleOrigin::BuiltIn,
1383 cwe_ids: Vec::new(),
1384 fix_suggestion: None,
1385 exploitability: Exploitability::default(),
1386 },
1387 }
1388 }
1389}
1390
1391impl Rule for ContentLengthAllocationRule {
1392 fn metadata(&self) -> &RuleMetadata {
1393 &self.metadata
1394 }
1395
1396 fn evaluate(
1397 &self,
1398 package: &MirPackage,
1399 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1400 ) -> Vec<Finding> {
1401 let mut findings = Vec::new();
1402
1403 for function in &package.functions {
1404 let allocations = detect_content_length_allocations(function);
1405
1406 for allocation in allocations {
1407 let mut evidence = vec![allocation.allocation_line.clone()];
1408
1409 let mut tainted: Vec<_> = allocation.tainted_vars.iter().cloned().collect();
1410 tainted.sort();
1411 tainted.dedup();
1412 if !tainted.is_empty() {
1413 evidence.push(format!("tainted length symbols: {}", tainted.join(", ")));
1414 }
1415
1416 findings.push(Finding {
1417 rule_id: self.metadata.id.clone(),
1418 rule_name: self.metadata.name.clone(),
1419 severity: self.metadata.default_severity,
1420 message: format!(
1421 "Potential unbounded allocation from Content-Length in `{}`",
1422 function.name
1423 ),
1424 function: function.name.clone(),
1425 function_signature: function.signature.clone(),
1426 evidence,
1427 span: function.span.clone(),
1428 confidence: Confidence::Medium,
1429 cwe_ids: Vec::new(),
1430 fix_suggestion: None,
1431 code_snippet: None,
1432 exploitability: Exploitability::default(),
1433 exploitability_score: Exploitability::default().score(),
1434 ..Default::default()
1435 });
1436 }
1437 }
1438
1439 findings
1440 }
1441}
1442
1443pub fn register_web_rules(engine: &mut crate::RuleEngine) {
1445 engine.register_rule(Box::new(NonHttpsUrlRule::new()));
1446 engine.register_rule(Box::new(DangerAcceptInvalidCertRule::new()));
1447 engine.register_rule(Box::new(OpensslVerifyNoneRule::new()));
1448 engine.register_rule(Box::new(CookieSecureAttributeRule::new()));
1449 engine.register_rule(Box::new(CorsWildcardRule::new()));
1450 engine.register_rule(Box::new(ConnectionStringPasswordRule::new()));
1451 engine.register_rule(Box::new(PasswordFieldMaskingRule::new()));
1452 engine.register_rule(Box::new(CleartextLoggingRule::new()));
1453 engine.register_rule(Box::new(TlsVerificationDisabledRule::new()));
1454 engine.register_rule(Box::new(AwsS3UnscopedAccessRule::new()));
1455 engine.register_rule(Box::new(ContentLengthAllocationRule::new()));
1456}