Skip to main content

mir_extractor/rules/
web.rs

1//! Web security rules.
2//!
3//! Rules detecting web application security issues:
4//! - AWS S3 unscoped access
5//! - Cleartext logging of sensitive data
6//! - Connection string password exposure
7//! - Cookie security attributes
8//! - CORS wildcard configuration
9//! - Password field masking
10//! - TLS verification disabled
11//! - Content-Length based allocations
12
13use crate::detect_content_length_allocations;
14use crate::{
15    Confidence, Exploitability, Finding, MirFunction, MirPackage, Rule, RuleMetadata, RuleOrigin,
16    Severity,
17};
18use std::collections::HashSet;
19
20// ============================================================================
21// RUSTCOLA011: Non-HTTPS URL Rule
22// ============================================================================
23
24/// Detects HTTP URLs in code where HTTPS should be used.
25pub 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
100// ============================================================================
101// RUSTCOLA012: Danger Accept Invalid Certs Rule
102// ============================================================================
103
104const 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
147/// Detects TLS certificate validation being disabled.
148pub 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
230// ============================================================================
231// RUSTCOLA013: OpenSSL Verify None Rule
232// ============================================================================
233
234struct 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        // Check for set_verify with NONE or empty() directly in the call
250        if lower.contains("set_verify") && (lower.contains("none") || lower.contains("empty()")) {
251            let mut supporting = Vec::new();
252            // Look for context in nearby lines
253            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        // Check for SslVerifyMode::empty() that's used in a later set_verify call
267        if lower.contains("sslverifymode::empty()") || lower.contains("sslverifymode::none") {
268            // Look for a subsequent set_verify call that might use this
269            for j in (i + 1)..function.body.len().min(i + 5) {
270                if function.body[j].to_lowercase().contains("set_verify") {
271                    // Found correlation - report the set_verify with the mode assignment as evidence
272                    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
287/// Detects OpenSSL configurations that disable certificate verification.
288pub 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        // Skip analyzer's own crate to avoid self-referential warnings
322        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
369// ============================================================================
370// RUSTCOLA042: Cookie Secure Attribute Rule
371// ============================================================================
372
373/// Detects cookies without Secure attribute.
374pub 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            // Look for cookie-related patterns
411            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            // Check if secure is set anywhere in the function
425            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
458// ============================================================================
459// RUSTCOLA043: CORS Wildcard Rule
460// ============================================================================
461
462/// Detects CORS configurations with wildcard origins.
463pub 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                    // Look for CORS with wildcard patterns
505                    (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
538// ============================================================================
539// RUSTCOLA060: Connection String Password Rule
540// ============================================================================
541
542/// Detects hardcoded passwords in connection strings.
543pub 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                    // Connection string patterns with embedded passwords
586                    (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
625// ============================================================================
626// RUSTCOLA061: Password Field Masking Rule
627// ============================================================================
628
629/// Detects password fields that may not be properly masked.
630pub 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            // Look for Debug/Display implementations with password fields
667            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
719// ============================================================================
720// RUSTCOLA075: Cleartext Logging Rule
721// ============================================================================
722
723/// Detects logging of sensitive data in cleartext.
724pub 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                    // Log macros with sensitive data
768                    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
806// ============================================================================
807// RUSTCOLA084: TLS Verification Disabled Rule
808// ============================================================================
809
810/// Comprehensive detection of disabled TLS certificate verification across
811/// multiple HTTP/TLS libraries.
812///
813/// Covered libraries:
814/// - native-tls: danger_accept_invalid_certs, danger_accept_invalid_hostnames
815/// - rustls: .dangerous(), DangerousClientConfigBuilder, ServerCertVerified::assertion()
816/// - reqwest: danger_accept_invalid_certs(true), danger_accept_invalid_hostnames(true)
817/// - hyper-tls: native-tls connector with verification disabled
818/// - OpenSSL: SSL_VERIFY_NONE
819pub 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            // Skip test functions (common to disable TLS verification in tests)
861            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                // --- native-tls patterns ---
874                // Pattern: TlsConnectorBuilder::danger_accept_invalid_certs(_, const true)
875                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                // Pattern: TlsConnectorBuilder::danger_accept_invalid_hostnames(_, const true)
896                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                // --- rustls patterns ---
915                // Pattern: ConfigBuilder::dangerous() - entering dangerous mode
916                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                // Pattern: DangerousClientConfigBuilder::with_custom_certificate_verifier
928                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                // Pattern: ServerCertVerified::assertion() - always-accept verifier
938                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                // --- openssl patterns (if using openssl crate) ---
946                // Pattern: set_verify(SslVerifyMode::NONE) or SSL_VERIFY_NONE
947                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                // --- Generic danger patterns ---
957                // Pattern: "danger" in function name being called with true
958                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            // Create findings for each dangerous pattern found
971            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
1000// ============================================================================
1001// RUSTCOLA085: AWS S3 Unscoped Access Rule
1002// ============================================================================
1003
1004/// Detects AWS S3 operations where bucket names, keys, or prefixes come from
1005/// untrusted sources (env vars, CLI args, etc.) without validation.
1006/// This can enable data exfiltration, unauthorized deletions, or path traversal.
1007pub 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    /// Check if there's validation before the S3 call
1035    fn has_validation(&self, lines: &[&str], s3_call_idx: usize) -> bool {
1036        // Look for validation patterns before the S3 call
1037        for i in 0..s3_call_idx {
1038            let trimmed = lines[i].trim();
1039
1040            // Allowlist check: contains() call typically used for allowlist validation
1041            if trimmed.contains("::contains(") && !trimmed.contains("str>::contains") {
1042                return true;
1043            }
1044
1045            // starts_with validation for prefix scoping
1046            if trimmed.contains("starts_with") && !trimmed.contains("trim_start") {
1047                return true;
1048            }
1049
1050            // Path traversal sanitization: replace("..", "")
1051            if trimmed.contains("replace") && trimmed.contains("\"..\"") {
1052                return true;
1053            }
1054
1055            // Explicit assertion/panic for invalid input
1056            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            // filter() for character sanitization
1065            if trimmed.contains("filter::<") && trimmed.contains("Chars") {
1066                return true;
1067            }
1068        }
1069        false
1070    }
1071
1072    /// Get the S3 operation severity based on the method
1073    fn get_operation_severity(&self, method: &str) -> Severity {
1074        if method.contains("delete") || method.contains("Delete") {
1075            Severity::High // Deletion is most dangerous (using High since no Critical)
1076        } else if method.contains("put") || method.contains("Put") || method.contains("copy") {
1077            Severity::High // Write operations
1078        } else {
1079            Severity::Medium // Read operations (list, get, head)
1080        }
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            // Skip test functions
1098            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            // Track untrusted variable sources
1109            let mut untrusted_vars: HashSet<String> = HashSet::new();
1110
1111            // First pass: identify untrusted sources (env::var, args)
1112            for (_idx, line) in lines.iter().enumerate() {
1113                let trimmed = line.trim();
1114
1115                // Detect env::var Result variable declarations
1116                // Pattern: let mut _4: std::result::Result<std::string::String, std::env::VarError>;
1117                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                // env::var call (older pattern)
1132                if trimmed.contains("var::<") && trimmed.contains("const \"") {
1133                    // Extract the target variable: _X = var::<&str>(...)
1134                    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                // env::args() collection
1143                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                // Propagate taint through Result::unwrap with VarError
1153                // Pattern: (((*_21) as variant#3).0: std::string::String) = Result::<std::string::String, VarError>::unwrap(move _4)
1154                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                    // Extract the destination variable
1162                    if let Some(eq_pos) = trimmed.find(" = ") {
1163                        let dest_part = trimmed[..eq_pos].trim();
1164                        // Handle async closure patterns like (((*_21) as variant#3).0: std::string::String)
1165                        if dest_part.contains(".0:") || dest_part.starts_with('_') {
1166                            // Mark the unwrapped result as tainted
1167                            if dest_part.starts_with('_') {
1168                                untrusted_vars.insert(dest_part.to_string());
1169                            }
1170                            // Also track the complex field reference
1171                            untrusted_vars.insert(dest_part.to_string());
1172                        }
1173                    }
1174                }
1175
1176                // Propagate taint through unwrap
1177                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                        // Check if source is tainted
1181                        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                // Propagate through index operations (args[1])
1193                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                // Propagate through simple assignments and copies
1206                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                // Propagate through reference operations to async state fields
1222                // Pattern: _10 = &(((*_22) as variant#3).0: std::string::String);
1223                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                        // Check if this references a tainted async state field
1230                        // The tainted field may be through any _N deref, so match on the field pattern
1231                        for tainted in untrusted_vars.clone() {
1232                            if tainted.contains("variant#") && tainted.contains(".0:") {
1233                                // Both are async state fields - likely same data
1234                                untrusted_vars.insert(var_part.to_string());
1235                                break;
1236                            }
1237                        }
1238                    }
1239                }
1240
1241                // Propagate through format! and string operations
1242                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            // Second pass: find S3 operations with untrusted parameters
1256            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                        // Skip if using const (hardcoded value - safe)
1277                        if trimmed.contains("const \"")
1278                            || trimmed.contains("const safe_")
1279                            || trimmed.contains("::ALLOWED_")
1280                        {
1281                            continue;
1282                        }
1283
1284                        // Check if any untrusted variable flows to this call
1285                        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                            // Check if there's validation before this call
1298                            if self.has_validation(&lines, idx) {
1299                                continue; // Validation found, skip
1300                            }
1301
1302                            // Determine operation type and severity
1303                            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; // Only report once per S3 call
1353                        }
1354                    }
1355                }
1356            }
1357        }
1358
1359        findings
1360    }
1361}
1362
1363// ============================================================================
1364// RUSTCOLA021: Content-Length based allocation (DoS risk)
1365// ============================================================================
1366
1367pub 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
1443/// Register all web security rules with the rule engine.
1444pub 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}