Skip to main content

mir_extractor/rules/
resource.rs

1//! Resource management rules.
2//!
3//! Rules detecting resource management issues:
4//! - File/directory permissions and handling
5//! - Path traversal and absolute path issues
6//! - Child process management
7//! - OpenOptions configuration issues
8//! - Hardcoded home paths
9//! - Build script network access
10//! - Unbounded allocations from untrusted input
11
12#![allow(dead_code)]
13
14use crate::line_has_world_writable_mode;
15use crate::prototypes;
16use crate::{
17    Confidence, Exploitability, Finding, MirFunction, MirPackage, Rule, RuleMetadata, RuleOrigin,
18    Severity,
19};
20use std::collections::HashSet;
21use std::fs;
22use std::path::Path;
23
24// ============================================================================
25// RUSTCOLA067: Spawned Child Process Not Waited On
26// ============================================================================
27
28/// Detects child processes spawned but not waited on, creating zombie processes.
29pub struct SpawnedChildNoWaitRule {
30    metadata: RuleMetadata,
31}
32
33impl SpawnedChildNoWaitRule {
34    pub fn new() -> Self {
35        Self {
36            metadata: RuleMetadata {
37                id: "RUSTCOLA067".to_string(),
38                name: "spawned-child-no-wait".to_string(),
39                short_description: "Spawned child process not waited on".to_string(),
40                full_description: "Detects child processes spawned via Command::spawn() that are \
41                    not waited on via wait(), status(), or wait_with_output(). Failing to wait \
42                    on spawned children creates zombie processes that consume system resources. \
43                    Implements Clippy's zombie_processes lint."
44                    .to_string(),
45                help_uri: None,
46                default_severity: Severity::Medium,
47                origin: RuleOrigin::BuiltIn,
48                cwe_ids: Vec::new(),
49                fix_suggestion: None,
50                exploitability: Exploitability::default(),
51            },
52        }
53    }
54}
55
56impl Rule for SpawnedChildNoWaitRule {
57    fn metadata(&self) -> &RuleMetadata {
58        &self.metadata
59    }
60
61    fn evaluate(
62        &self,
63        package: &MirPackage,
64        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
65    ) -> Vec<Finding> {
66        if package.crate_name == "mir-extractor" {
67            return Vec::new();
68        }
69
70        let mut findings = Vec::new();
71
72        for function in &package.functions {
73            if function.name.contains("SpawnedChildNoWaitRule") {
74                continue;
75            }
76
77            let body_str = function.body.join("\n");
78            let lower = body_str.to_lowercase();
79
80            let spawn_count = lower.matches("::spawn(").count();
81            if spawn_count == 0 {
82                continue;
83            }
84
85            let wait_count = lower.matches("child::wait(").count()
86                + lower.matches("::wait_with_output(").count();
87
88            let mut child_status_count = 0;
89            for line in &function.body {
90                let line_lower = line.to_lowercase();
91                if line_lower.contains("child") && line_lower.contains("::status(") {
92                    child_status_count += 1;
93                }
94            }
95
96            let total_wait_count = wait_count + child_status_count;
97
98            if spawn_count > total_wait_count {
99                let evidence: Vec<String> = function
100                    .body
101                    .iter()
102                    .filter(|line| line.to_lowercase().contains("::spawn("))
103                    .take(5)
104                    .map(|line| line.trim().to_string())
105                    .collect();
106
107                findings.push(Finding {
108                    rule_id: self.metadata.id.clone(),
109                    rule_name: self.metadata.name.clone(),
110                    severity: self.metadata.default_severity,
111                    message: format!(
112                        "Child process spawned in `{}` but not waited on - call wait(), \
113                        status(), or wait_with_output() to prevent zombie processes",
114                        function.name
115                    ),
116                    function: function.name.clone(),
117                    function_signature: function.signature.clone(),
118                    evidence,
119                    span: function.span.clone(),
120                    confidence: Confidence::Medium,
121                    cwe_ids: Vec::new(),
122                    fix_suggestion: None,
123                    code_snippet: None,
124                    exploitability: Exploitability::default(),
125                    exploitability_score: Exploitability::default().score(),
126                ..Default::default()
127                });
128            }
129        }
130
131        findings
132    }
133}
134
135// ============================================================================
136// RUSTCOLA028: Permissions::set_readonly(false)
137// ============================================================================
138
139/// Detects permissions being set to non-readonly, potentially exposing files.
140pub struct PermissionsSetReadonlyFalseRule {
141    metadata: RuleMetadata,
142}
143
144impl PermissionsSetReadonlyFalseRule {
145    pub fn new() -> Self {
146        Self {
147            metadata: RuleMetadata {
148                id: "RUSTCOLA028".to_string(),
149                name: "permissions-set-readonly-false".to_string(),
150                short_description: "Permissions::set_readonly(false) detected".to_string(),
151                full_description: "Flags calls to std::fs::Permissions::set_readonly(false) \
152                    which downgrade filesystem permissions and can leave files world-writable \
153                    on Unix targets."
154                    .to_string(),
155                help_uri: None,
156                default_severity: Severity::Medium,
157                origin: RuleOrigin::BuiltIn,
158                cwe_ids: Vec::new(),
159                fix_suggestion: None,
160                exploitability: Exploitability::default(),
161            },
162        }
163    }
164}
165
166impl Rule for PermissionsSetReadonlyFalseRule {
167    fn metadata(&self) -> &RuleMetadata {
168        &self.metadata
169    }
170
171    fn evaluate(
172        &self,
173        package: &MirPackage,
174        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
175    ) -> Vec<Finding> {
176        if package.crate_name == "mir-extractor" {
177            return Vec::new();
178        }
179
180        let mut findings = Vec::new();
181
182        for function in &package.functions {
183            let mut evidence = Vec::new();
184
185            for line in &function.body {
186                if line.contains("set_readonly(") && line.contains("false") {
187                    evidence.push(line.trim().to_string());
188                }
189            }
190
191            if evidence.is_empty() {
192                continue;
193            }
194
195            findings.push(Finding {
196                rule_id: self.metadata.id.clone(),
197                rule_name: self.metadata.name.clone(),
198                severity: self.metadata.default_severity,
199                message: format!(
200                    "Permissions::set_readonly(false) used in `{}`",
201                    function.name
202                ),
203                function: function.name.clone(),
204                function_signature: function.signature.clone(),
205                evidence,
206                span: function.span.clone(),
207                confidence: Confidence::Medium,
208                cwe_ids: Vec::new(),
209                fix_suggestion: None,
210                code_snippet: None,
211                exploitability: Exploitability::default(),
212                exploitability_score: Exploitability::default().score(),
213            ..Default::default()
214            });
215        }
216
217        findings
218    }
219}
220
221// ============================================================================
222// RUSTCOLA029: World-Writable File Mode
223// ============================================================================
224
225/// Detects world-writable file permissions (0o777, 0o666, etc.).
226pub struct WorldWritableModeRule {
227    metadata: RuleMetadata,
228}
229
230impl WorldWritableModeRule {
231    pub fn new() -> Self {
232        Self {
233            metadata: RuleMetadata {
234                id: "RUSTCOLA029".to_string(),
235                name: "world-writable-mode".to_string(),
236                short_description: "World-writable file mode detected".to_string(),
237                full_description:
238                    "Detects explicit world-writable permission masks (e.g., 0o777/0o666) \
239                    passed to PermissionsExt::set_mode, OpenOptionsExt::mode, or similar builders."
240                        .to_string(),
241                help_uri: None,
242                default_severity: Severity::High,
243                origin: RuleOrigin::BuiltIn,
244                cwe_ids: Vec::new(),
245                fix_suggestion: None,
246                exploitability: Exploitability::default(),
247            },
248        }
249    }
250}
251
252impl Rule for WorldWritableModeRule {
253    fn metadata(&self) -> &RuleMetadata {
254        &self.metadata
255    }
256
257    fn evaluate(
258        &self,
259        package: &MirPackage,
260        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
261    ) -> Vec<Finding> {
262        if package.crate_name == "mir-extractor" {
263            return Vec::new();
264        }
265
266        let mut findings = Vec::new();
267
268        for function in &package.functions {
269            let mut evidence = Vec::new();
270
271            for line in &function.body {
272                if line_has_world_writable_mode(line) {
273                    evidence.push(line.trim().to_string());
274                }
275            }
276
277            if evidence.is_empty() {
278                continue;
279            }
280
281            findings.push(Finding {
282                rule_id: self.metadata.id.clone(),
283                rule_name: self.metadata.name.clone(),
284                severity: self.metadata.default_severity,
285                message: format!("World-writable permission mask set in `{}`", function.name),
286                function: function.name.clone(),
287                function_signature: function.signature.clone(),
288                evidence,
289                span: function.span.clone(),
290                confidence: Confidence::Medium,
291                cwe_ids: Vec::new(),
292                fix_suggestion: None,
293                code_snippet: None,
294                exploitability: Exploitability::default(),
295                exploitability_score: Exploitability::default().score(),
296            ..Default::default()
297            });
298        }
299
300        findings
301    }
302}
303
304// ============================================================================
305// RUSTCOLA032: OpenOptions Missing Truncate
306// ============================================================================
307
308/// Detects OpenOptions with write+create but no truncate or append.
309pub struct OpenOptionsMissingTruncateRule {
310    metadata: RuleMetadata,
311}
312
313impl OpenOptionsMissingTruncateRule {
314    pub fn new() -> Self {
315        Self {
316            metadata: RuleMetadata {
317                id: "RUSTCOLA032".to_string(),
318                name: "openoptions-missing-truncate".to_string(),
319                short_description: "File created with write(true) without truncate or append".to_string(),
320                full_description: "Detects OpenOptions::new().write(true).create(true) patterns \
321                    that don't specify .truncate(true) or .append(true). Old file contents may remain, \
322                    leading to stale data disclosure or corruption.".to_string(),
323                help_uri: Some("https://rust-lang.github.io/rust-clippy/master/index.html#suspicious_open_options".to_string()),
324                default_severity: Severity::Medium,
325                origin: RuleOrigin::BuiltIn,
326                cwe_ids: Vec::new(),
327                fix_suggestion: None,
328                exploitability: Exploitability::default(),
329            },
330        }
331    }
332}
333
334impl Rule for OpenOptionsMissingTruncateRule {
335    fn metadata(&self) -> &RuleMetadata {
336        &self.metadata
337    }
338
339    fn cache_key(&self) -> String {
340        format!("{}:v1", self.metadata.id)
341    }
342
343    fn evaluate(
344        &self,
345        package: &MirPackage,
346        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
347    ) -> Vec<Finding> {
348        let mut findings = Vec::new();
349
350        for function in &package.functions {
351            let mut has_write_true = false;
352            let mut has_create_true = false;
353            let mut has_truncate_or_append = false;
354            let mut open_options_start_line = None;
355            let mut evidence_lines = Vec::new();
356
357            for (idx, line) in function.body.iter().enumerate() {
358                if line.contains("OpenOptions::new()") {
359                    open_options_start_line = Some(idx);
360                    has_write_true = false;
361                    has_create_true = false;
362                    has_truncate_or_append = false;
363                    evidence_lines.clear();
364                    evidence_lines.push(line.trim().to_string());
365                }
366
367                if let Some(start) = open_options_start_line {
368                    if idx <= start + 20 {
369                        if line.contains(".write(true)")
370                            || (line.contains("OpenOptions::write") && line.contains("const true"))
371                        {
372                            has_write_true = true;
373                            if !evidence_lines.iter().any(|e| e.contains(line.trim())) {
374                                evidence_lines.push(line.trim().to_string());
375                            }
376                        }
377
378                        if line.contains(".create(true)")
379                            || (line.contains("OpenOptions::create") && line.contains("const true"))
380                        {
381                            has_create_true = true;
382                            if !evidence_lines.iter().any(|e| e.contains(line.trim())) {
383                                evidence_lines.push(line.trim().to_string());
384                            }
385                        }
386
387                        if line.contains(".truncate(true)")
388                            || line.contains(".append(true)")
389                            || (line.contains("OpenOptions::truncate")
390                                && line.contains("const true"))
391                            || (line.contains("OpenOptions::append") && line.contains("const true"))
392                        {
393                            has_truncate_or_append = true;
394                        }
395
396                        if line.contains(".open(") || line.contains("OpenOptions::open") {
397                            if has_write_true && has_create_true && !has_truncate_or_append {
398                                findings.push(Finding {
399                                    rule_id: self.metadata.id.clone(),
400                                    rule_name: self.metadata.name.clone(),
401                                    severity: self.metadata.default_severity,
402                                    message: format!(
403                                        "File opened with write(true) and create(true) but no truncate(true) or append(true) in `{}`",
404                                        function.name
405                                    ),
406                                    function: function.name.clone(),
407                                    function_signature: function.signature.clone(),
408                                    evidence: evidence_lines.clone(),
409                                    span: function.span.clone(),
410                    confidence: Confidence::Medium,
411                    cwe_ids: Vec::new(),
412                    fix_suggestion: None,
413                    code_snippet: None,
414                exploitability: Exploitability::default(),
415                exploitability_score: Exploitability::default().score(),
416                                ..Default::default()
417                                });
418                            }
419                            open_options_start_line = None;
420                        }
421                    } else {
422                        open_options_start_line = None;
423                    }
424                }
425            }
426        }
427
428        findings
429    }
430}
431
432// ============================================================================
433// RUSTCOLA055: Unix Permissions Not Octal
434// ============================================================================
435
436/// Detects Unix permissions passed as decimal instead of octal notation.
437pub struct UnixPermissionsNotOctalRule {
438    metadata: RuleMetadata,
439}
440
441impl UnixPermissionsNotOctalRule {
442    pub fn new() -> Self {
443        Self {
444            metadata: RuleMetadata {
445                id: "RUSTCOLA055".to_string(),
446                name: "unix-permissions-not-octal".to_string(),
447                short_description: "Unix file permissions not in octal notation".to_string(),
448                full_description:
449                    "Detects Unix file permissions passed as decimal literals instead \
450                    of octal notation. Decimal literals like 644 or 755 are confusing because they \
451                    look like octal but are interpreted as decimal. Use explicit octal notation \
452                    with 0o prefix (e.g., 0o644, 0o755)."
453                        .to_string(),
454                help_uri: None,
455                default_severity: Severity::Medium,
456                origin: RuleOrigin::BuiltIn,
457                cwe_ids: Vec::new(),
458                fix_suggestion: None,
459                exploitability: Exploitability::default(),
460            },
461        }
462    }
463
464    fn looks_like_decimal_permission(&self, function: &MirFunction) -> bool {
465        let body_str = format!("{:?}", function.body);
466
467        let has_permission_api = body_str.contains("from_mode")
468            || body_str.contains("set_mode")
469            || body_str.contains("chmod")
470            || body_str.contains("DirBuilder");
471
472        if !has_permission_api {
473            return false;
474        }
475
476        let suspicious_decimals = [
477            "644_u32", "755_u32", "777_u32", "666_u32", "600_u32", "700_u32", "750_u32", "640_u32",
478            "= 644", "= 755", "= 777", "= 666", "= 600", "= 700", "= 750", "= 640",
479        ];
480
481        for pattern in &suspicious_decimals {
482            if body_str.contains(pattern) {
483                let context_check = format!("0o{}", pattern);
484                if !body_str.contains(&context_check) {
485                    return true;
486                }
487            }
488        }
489
490        false
491    }
492}
493
494impl Rule for UnixPermissionsNotOctalRule {
495    fn metadata(&self) -> &RuleMetadata {
496        &self.metadata
497    }
498
499    fn evaluate(
500        &self,
501        package: &MirPackage,
502        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
503    ) -> Vec<Finding> {
504        let mut findings = Vec::new();
505
506        for function in &package.functions {
507            if self.looks_like_decimal_permission(function) {
508                let body_str = format!("{:?}", function.body);
509                let mut evidence = Vec::new();
510
511                for line in body_str.lines().take(200) {
512                    if (line.contains("from_mode")
513                        || line.contains("set_mode")
514                        || line.contains("chmod")
515                        || line.contains("DirBuilder"))
516                        && (line.contains("644")
517                            || line.contains("755")
518                            || line.contains("777")
519                            || line.contains("666")
520                            || line.contains("600")
521                            || line.contains("700"))
522                    {
523                        evidence.push(line.trim().to_string());
524                        if evidence.len() >= 3 {
525                            break;
526                        }
527                    }
528                }
529
530                findings.push(Finding {
531                    rule_id: self.metadata.id.clone(),
532                    rule_name: self.metadata.name.clone(),
533                    severity: self.metadata.default_severity,
534                    message: "Unix file permissions use decimal notation instead of octal. \
535                        Use 0o prefix (e.g., 0o644 for rw-r--r--, 0o755 for rwxr-xr-x)."
536                        .to_string(),
537                    function: function.name.clone(),
538                    function_signature: function.signature.clone(),
539                    evidence,
540                    span: None,
541                    ..Default::default()
542                });
543            }
544        }
545
546        findings
547    }
548}
549
550// ============================================================================
551// RUSTCOLA056: OpenOptions Inconsistent Flags
552// ============================================================================
553
554/// Detects OpenOptions with dangerous or inconsistent flag combinations.
555pub struct OpenOptionsInconsistentFlagsRule {
556    metadata: RuleMetadata,
557}
558
559impl OpenOptionsInconsistentFlagsRule {
560    pub fn new() -> Self {
561        Self {
562            metadata: RuleMetadata {
563                id: "RUSTCOLA056".to_string(),
564                name: "openoptions-inconsistent-flags".to_string(),
565                short_description: "OpenOptions with inconsistent flag combinations".to_string(),
566                full_description: "Detects OpenOptions with dangerous or inconsistent flag \
567                    combinations: create without write, truncate without write, or append with truncate.".to_string(),
568                help_uri: None,
569                default_severity: Severity::Medium,
570                origin: RuleOrigin::BuiltIn,
571                cwe_ids: Vec::new(),
572                fix_suggestion: None,
573                exploitability: Exploitability::default(),
574            },
575        }
576    }
577
578    fn check_openoptions_flags(&self, function: &MirFunction) -> Option<String> {
579        let body_str = function.body.join("\n");
580
581        if !body_str.contains("OpenOptions") {
582            return None;
583        }
584
585        let has_write = body_str.contains(".write(true)")
586            || (body_str.contains("OpenOptions::write") && body_str.contains("const true"));
587        let has_create = body_str.contains(".create(true)")
588            || (body_str.contains("OpenOptions::create") && body_str.contains("const true"));
589        let has_create_new = body_str.contains(".create_new(true)")
590            || (body_str.contains("OpenOptions::create_new") && body_str.contains("const true"));
591        let has_truncate = body_str.contains(".truncate(true)")
592            || (body_str.contains("OpenOptions::truncate") && body_str.contains("const true"));
593        let has_append = body_str.contains(".append(true)")
594            || (body_str.contains("OpenOptions::append") && body_str.contains("const true"));
595
596        if (has_create || has_create_new) && !has_write && !has_append {
597            return Some("create(true) without write(true) or append(true). File will be created but not writable.".to_string());
598        }
599
600        if has_truncate && !has_write && !has_append {
601            return Some("truncate(true) without write(true). This would truncate the file but not allow writing.".to_string());
602        }
603
604        if has_append && has_truncate {
605            return Some(
606                "append(true) with truncate(true). These flags are contradictory.".to_string(),
607            );
608        }
609
610        None
611    }
612}
613
614impl Rule for OpenOptionsInconsistentFlagsRule {
615    fn metadata(&self) -> &RuleMetadata {
616        &self.metadata
617    }
618
619    fn evaluate(
620        &self,
621        package: &MirPackage,
622        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
623    ) -> Vec<Finding> {
624        let mut findings = Vec::new();
625
626        for function in &package.functions {
627            if let Some(issue_description) = self.check_openoptions_flags(function) {
628                let mut evidence = Vec::new();
629
630                for line in &function.body {
631                    if line.contains("OpenOptions")
632                        || line.contains(".write")
633                        || line.contains(".create")
634                        || line.contains(".truncate")
635                        || line.contains(".append")
636                    {
637                        evidence.push(line.trim().to_string());
638                        if evidence.len() >= 5 {
639                            break;
640                        }
641                    }
642                }
643
644                findings.push(Finding {
645                    rule_id: self.metadata.id.clone(),
646                    rule_name: self.metadata.name.clone(),
647                    severity: self.metadata.default_severity,
648                    message: format!(
649                        "OpenOptions has inconsistent flag combination: {}",
650                        issue_description
651                    ),
652                    function: function.name.clone(),
653                    function_signature: function.signature.clone(),
654                    evidence,
655                    span: None,
656                    ..Default::default()
657                });
658            }
659        }
660
661        findings
662    }
663}
664
665// ============================================================================
666// RUSTCOLA058: Absolute Path in Join
667// ============================================================================
668
669/// Detects absolute paths passed to Path::join() or PathBuf::push().
670pub struct AbsolutePathInJoinRule {
671    metadata: RuleMetadata,
672}
673
674impl AbsolutePathInJoinRule {
675    pub fn new() -> Self {
676        Self {
677            metadata: RuleMetadata {
678                id: "RUSTCOLA058".to_string(),
679                name: "absolute-path-in-join".to_string(),
680                short_description: "Absolute path passed to Path::join() or PathBuf::push()"
681                    .to_string(),
682                full_description: "Detects when Path::join() or PathBuf::push() receives an \
683                    absolute path argument. Absolute paths nullify the base path, defeating \
684                    sanitization and potentially enabling path traversal attacks."
685                    .to_string(),
686                help_uri: None,
687                default_severity: Severity::High,
688                origin: RuleOrigin::BuiltIn,
689                cwe_ids: Vec::new(),
690                fix_suggestion: None,
691                exploitability: Exploitability::default(),
692            },
693        }
694    }
695
696    fn looks_like_absolute_path_join(&self, function: &MirFunction) -> bool {
697        let body_str = format!("{:?}", function.body);
698
699        let has_path_ops = body_str.contains("Path::join")
700            || body_str.contains("PathBuf::join")
701            || body_str.contains("PathBuf::push");
702
703        if !has_path_ops {
704            return false;
705        }
706
707        let absolute_patterns = [
708            "\"/",
709            "\"C:",
710            "\"D:",
711            "\"E:",
712            "\"F:",
713            "\"/etc",
714            "\"/usr",
715            "\"/var",
716            "\"/tmp",
717            "\"/home",
718            "\"/root",
719            "\"/sys",
720            "\"/proc",
721            "\"/dev",
722            "\"C:\\\\",
723            "\"/Users",
724            "\"/Applications",
725        ];
726
727        for pattern in &absolute_patterns {
728            if body_str.contains(pattern) {
729                return true;
730            }
731        }
732
733        false
734    }
735}
736
737impl Rule for AbsolutePathInJoinRule {
738    fn metadata(&self) -> &RuleMetadata {
739        &self.metadata
740    }
741
742    fn evaluate(
743        &self,
744        package: &MirPackage,
745        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
746    ) -> Vec<Finding> {
747        let mut findings = Vec::new();
748
749        for function in &package.functions {
750            if self.looks_like_absolute_path_join(function) {
751                let mut evidence = Vec::new();
752
753                for line in &function.body {
754                    let has_join_or_push = (line.contains("Path::join")
755                        || line.contains("PathBuf::join")
756                        || line.contains("PathBuf::push"))
757                        && line.contains("const");
758
759                    if !has_join_or_push {
760                        continue;
761                    }
762
763                    let has_absolute = line.contains("const \"/")
764                        || line.contains("const \"C:")
765                        || line.contains("const \"D:");
766
767                    if has_absolute {
768                        evidence.push(line.trim().to_string());
769                        if evidence.len() >= 5 {
770                            break;
771                        }
772                    }
773                }
774
775                if !evidence.is_empty() {
776                    findings.push(Finding {
777                        rule_id: self.metadata.id.clone(),
778                        rule_name: self.metadata.name.clone(),
779                        severity: self.metadata.default_severity,
780                        message: "Absolute path passed to Path::join() or PathBuf::push(). \
781                            This nullifies the base path, potentially enabling path traversal."
782                            .to_string(),
783                        function: function.name.clone(),
784                        function_signature: function.signature.clone(),
785                        evidence,
786                        span: None,
787                        ..Default::default()
788                    });
789                }
790            }
791        }
792
793        findings
794    }
795}
796
797// ============================================================================
798// RUSTCOLA014: Hardcoded Home Path Rule
799// ============================================================================
800
801/// Detects hard-coded paths to user home directories.
802pub struct HardcodedHomePathRule {
803    metadata: RuleMetadata,
804}
805
806impl HardcodedHomePathRule {
807    pub fn new() -> Self {
808        Self {
809            metadata: RuleMetadata {
810                id: "RUSTCOLA014".to_string(),
811                name: "hardcoded-home-path".to_string(),
812                short_description: "Hard-coded home directory path detected".to_string(),
813                full_description: "Detects absolute paths to user home directories hard-coded in string literals. Hard-coded home paths reduce portability and create security issues: (1) Code breaks when run under different users or in containers/CI, (2) Exposes username information in source code, (3) Prevents proper multi-user deployments, (4) Makes code non-portable across operating systems. Use environment variables (HOME, USERPROFILE), std::env::home_dir(), or the dirs crate instead. Detects patterns like /home/username, /Users/username, C:\\Users\\username, and ~username (with username).".to_string(),
814                help_uri: None,
815                default_severity: Severity::Low,
816                origin: RuleOrigin::BuiltIn,
817                cwe_ids: Vec::new(),
818                fix_suggestion: None,
819                exploitability: Exploitability::default(),
820            },
821        }
822    }
823}
824
825impl Rule for HardcodedHomePathRule {
826    fn metadata(&self) -> &RuleMetadata {
827        &self.metadata
828    }
829
830    fn evaluate(
831        &self,
832        package: &MirPackage,
833        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
834    ) -> Vec<Finding> {
835        if package.crate_name == "mir-extractor" {
836            return Vec::new();
837        }
838
839        let mut findings = Vec::new();
840
841        for function in &package.functions {
842            // Self-exclusion
843            if function.name.contains("HardcodedHomePathRule") {
844                continue;
845            }
846
847            let body_str = function.body.join("\n");
848
849            // Patterns for hard-coded home directory paths
850            // Unix/Linux: /home/username
851            // macOS: /Users/username
852            // Windows: C:\Users\username or C:/Users/username
853            // Tilde with username: ~username (but not ~/something)
854            let home_patterns = ["\"/home/", "\"/Users/", "\"C:\\\\Users\\\\", "\"C:/Users/"];
855
856            let mut found_hardcoded = false;
857
858            for pattern in &home_patterns {
859                if body_str.contains(pattern) {
860                    found_hardcoded = true;
861                    break;
862                }
863            }
864
865            // Check for ~username (tilde with username, not just ~/)
866            // Look for "~ followed by non-slash characters
867            if body_str.contains("\"~") && !body_str.contains("\"~/") {
868                found_hardcoded = true;
869            }
870
871            if !found_hardcoded {
872                continue;
873            }
874
875            // Collect evidence lines
876            let evidence: Vec<String> = function
877                .body
878                .iter()
879                .filter(|line| {
880                    home_patterns.iter().any(|p| line.contains(p))
881                        || (line.contains("\"~") && !line.contains("\"~/"))
882                })
883                .take(5)
884                .map(|line| line.trim().to_string())
885                .collect();
886
887            if evidence.is_empty() {
888                continue;
889            }
890
891            findings.push(Finding {
892                rule_id: self.metadata.id.clone(),
893                rule_name: self.metadata.name.clone(),
894                severity: self.metadata.default_severity,
895                message: format!(
896                    "Hard-coded home directory path in `{}` - use environment variables (HOME/USERPROFILE) or std::env::home_dir() instead",
897                    function.name
898                ),
899                function: function.name.clone(),
900                function_signature: function.signature.clone(),
901                evidence,
902                span: function.span.clone(),
903                    confidence: Confidence::Medium,
904                    cwe_ids: Vec::new(),
905                    fix_suggestion: None,
906                    code_snippet: None,
907                exploitability: Exploitability::default(),
908                exploitability_score: Exploitability::default().score(),
909            ..Default::default()
910            });
911        }
912
913        findings
914    }
915}
916
917// ============================================================================
918// RUSTCOLA097: Build Script Network Access Rule
919// ============================================================================
920
921/// Detects network access in build scripts, which is a supply-chain security risk.
922pub struct BuildScriptNetworkRule {
923    metadata: RuleMetadata,
924}
925
926impl BuildScriptNetworkRule {
927    pub fn new() -> Self {
928        Self {
929            metadata: RuleMetadata {
930                id: "RUSTCOLA097".to_string(),
931                name: "build-script-network-access".to_string(),
932                short_description: "Network access detected in build script".to_string(),
933                full_description: "Build scripts (build.rs) should not perform network requests, download files, or spawn processes that contact external systems. This is a supply-chain security risk - malicious dependencies could exfiltrate data or download malware at build time. Use vendored dependencies or pre-downloaded assets instead.".to_string(),
934                help_uri: Some("https://doc.rust-lang.org/cargo/reference/build-scripts.html".to_string()),
935                default_severity: Severity::High,
936                origin: RuleOrigin::BuiltIn,
937                cwe_ids: Vec::new(),
938                fix_suggestion: None,
939                exploitability: Exploitability::default(),
940            },
941        }
942    }
943
944    /// Network-related patterns to detect
945    fn network_patterns() -> &'static [(&'static str, &'static str)] {
946        &[
947            // HTTP client libraries
948            ("reqwest::blocking::get", "reqwest HTTP client"),
949            ("reqwest::get", "reqwest HTTP client"),
950            ("reqwest::Client", "reqwest HTTP client"),
951            ("ureq::get", "ureq HTTP client"),
952            ("ureq::post", "ureq HTTP client"),
953            ("ureq::Agent", "ureq HTTP client"),
954            ("hyper::Client", "hyper HTTP client"),
955            ("curl::easy::Easy", "curl library"),
956            ("attohttpc::", "attohttpc HTTP client"),
957            ("minreq::", "minreq HTTP client"),
958            ("isahc::", "isahc HTTP client"),
959            // Network primitives
960            ("TcpStream::connect", "raw TCP connection"),
961            ("UdpSocket::bind", "raw UDP socket"),
962            ("std::net::TcpStream", "TCP network access"),
963            ("tokio::net::", "tokio network access"),
964            ("async_std::net::", "async-std network access"),
965            ("to_socket_addrs", "DNS lookup"),
966            // Dangerous commands
967            ("Command::new(\"curl\")", "curl command"),
968            ("Command::new(\"wget\")", "wget command"),
969            ("Command::new(\"fetch\")", "fetch command"),
970            (
971                "Command::new(\"git\")",
972                "git command (may clone from network)",
973            ),
974            ("Command::new(\"npm\")", "npm command (network access)"),
975            ("Command::new(\"pip\")", "pip command (network access)"),
976            (
977                "Command::new(\"cargo\")",
978                "cargo command (may download crates)",
979            ),
980        ]
981    }
982
983    /// Safe patterns that shouldn't trigger even if they look like network access
984    fn safe_patterns() -> &'static [&'static str] {
985        &[
986            "// SAFE:",
987            "// Safe:",
988            "#[allow(",
989            "mock",
990            "test",
991            "localhost",
992            "127.0.0.1",
993            "::1",
994        ]
995    }
996}
997
998impl Rule for BuildScriptNetworkRule {
999    fn metadata(&self) -> &RuleMetadata {
1000        &self.metadata
1001    }
1002
1003    fn evaluate(
1004        &self,
1005        package: &MirPackage,
1006        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1007    ) -> Vec<Finding> {
1008        if package.crate_name == "mir-extractor" {
1009            return Vec::new();
1010        }
1011
1012        let mut findings = Vec::new();
1013        let crate_root = Path::new(&package.crate_root);
1014
1015        if !crate_root.exists() {
1016            return findings;
1017        }
1018
1019        // Only scan build.rs files
1020        let build_rs = crate_root.join("build.rs");
1021        if !build_rs.exists() {
1022            return findings;
1023        }
1024
1025        let content = match fs::read_to_string(&build_rs) {
1026            Ok(c) => c,
1027            Err(_) => return findings,
1028        };
1029
1030        let lines: Vec<&str> = content.lines().collect();
1031        let mut current_fn_name = String::new();
1032
1033        for (idx, line) in lines.iter().enumerate() {
1034            let trimmed = line.trim();
1035
1036            // Track function names
1037            if trimmed.contains("fn ") {
1038                if let Some(fn_pos) = trimmed.find("fn ") {
1039                    let after_fn = &trimmed[fn_pos + 3..];
1040                    if let Some(paren_pos) = after_fn.find('(') {
1041                        current_fn_name = after_fn[..paren_pos].trim().to_string();
1042                    }
1043                }
1044            }
1045
1046            // Skip comments
1047            if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1048                continue;
1049            }
1050
1051            // Check for safe patterns first
1052            let is_safe = Self::safe_patterns().iter().any(|p| line.contains(p));
1053            if is_safe {
1054                continue;
1055            }
1056
1057            // Check for network patterns
1058            for (pattern, description) in Self::network_patterns() {
1059                if line.contains(pattern) {
1060                    let location = format!("build.rs:{}", idx + 1);
1061                    findings.push(Finding {
1062                        rule_id: self.metadata.id.clone(),
1063                        rule_name: self.metadata.name.clone(),
1064                        severity: self.metadata.default_severity,
1065                        message: format!(
1066                            "{} detected in build script function `{}`. Build scripts should not perform network requests - this is a supply-chain security risk.",
1067                            description, current_fn_name
1068                        ),
1069                        function: location,
1070                        function_signature: current_fn_name.clone(),
1071                        evidence: vec![trimmed.to_string()],
1072                        span: None,
1073                    ..Default::default()
1074                    });
1075                    break; // Only report once per line
1076                }
1077            }
1078        }
1079
1080        findings
1081    }
1082}
1083
1084// ============================================================================
1085// RUSTCOLA024: Unbounded allocation from tainted input
1086// ============================================================================
1087
1088pub struct UnboundedAllocationRule {
1089    metadata: RuleMetadata,
1090}
1091
1092impl UnboundedAllocationRule {
1093    pub fn new() -> Self {
1094        Self {
1095            metadata: RuleMetadata {
1096                id: "RUSTCOLA024".to_string(),
1097                name: "unbounded-allocation".to_string(),
1098                short_description: "Allocation sized from tainted length without guard".to_string(),
1099                full_description: "Detects allocations (`with_capacity`, `reserve*`) that rely on tainted length values (parameters, `.len()` on attacker data, etc.) without bounding them, enabling memory exhaustion.".to_string(),
1100                help_uri: Some("https://github.com/Opus-the-penguin/Rust-cola/blob/main/docs/security-rule-backlog.md#resource-management--dos".to_string()),
1101                default_severity: Severity::High,
1102                origin: RuleOrigin::BuiltIn,
1103                cwe_ids: Vec::new(),
1104                fix_suggestion: None,
1105                exploitability: Exploitability::default(),
1106            },
1107        }
1108    }
1109}
1110
1111impl Rule for UnboundedAllocationRule {
1112    fn metadata(&self) -> &RuleMetadata {
1113        &self.metadata
1114    }
1115
1116    fn evaluate(
1117        &self,
1118        package: &MirPackage,
1119        _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1120    ) -> Vec<Finding> {
1121        if package.crate_name == "mir-extractor" {
1122            return Vec::new();
1123        }
1124
1125        let mut findings = Vec::new();
1126        let options = prototypes::PrototypeOptions::default();
1127
1128        for function in &package.functions {
1129            let specialized =
1130                prototypes::detect_content_length_allocations_with_options(function, &options);
1131            let specialized_lines: HashSet<_> = specialized
1132                .iter()
1133                .map(|alloc| alloc.allocation_line.clone())
1134                .collect();
1135
1136            let allocations =
1137                prototypes::detect_unbounded_allocations_with_options(function, &options);
1138
1139            for allocation in allocations {
1140                if specialized_lines.contains(&allocation.allocation_line) {
1141                    continue;
1142                }
1143
1144                let mut evidence = vec![allocation.allocation_line.clone()];
1145
1146                let mut tainted: Vec<_> = allocation.tainted_vars.iter().cloned().collect();
1147                tainted.sort();
1148                tainted.dedup();
1149                if !tainted.is_empty() {
1150                    evidence.push(format!("tainted length symbols: {}", tainted.join(", ")));
1151                }
1152
1153                findings.push(Finding {
1154                    rule_id: self.metadata.id.clone(),
1155                    rule_name: self.metadata.name.clone(),
1156                    severity: self.metadata.default_severity,
1157                    message: format!(
1158                        "Potential unbounded allocation from tainted input in `{}`",
1159                        function.name
1160                    ),
1161                    function: function.name.clone(),
1162                    function_signature: function.signature.clone(),
1163                    evidence,
1164                    span: function.span.clone(),
1165                    confidence: Confidence::Medium,
1166                    cwe_ids: Vec::new(),
1167                    fix_suggestion: None,
1168                    code_snippet: None,
1169                    exploitability: Exploitability::default(),
1170                    exploitability_score: Exploitability::default().score(),
1171                ..Default::default()
1172                });
1173            }
1174        }
1175
1176        findings
1177    }
1178}
1179
1180// ============================================================================
1181// Registration
1182// ============================================================================
1183
1184/// Register all resource management rules with the rule engine.
1185pub fn register_resource_rules(engine: &mut crate::RuleEngine) {
1186    engine.register_rule(Box::new(SpawnedChildNoWaitRule::new()));
1187    engine.register_rule(Box::new(PermissionsSetReadonlyFalseRule::new()));
1188    engine.register_rule(Box::new(WorldWritableModeRule::new()));
1189    engine.register_rule(Box::new(OpenOptionsMissingTruncateRule::new()));
1190    engine.register_rule(Box::new(UnixPermissionsNotOctalRule::new()));
1191    engine.register_rule(Box::new(OpenOptionsInconsistentFlagsRule::new()));
1192    engine.register_rule(Box::new(AbsolutePathInJoinRule::new()));
1193    engine.register_rule(Box::new(HardcodedHomePathRule::new()));
1194    engine.register_rule(Box::new(BuildScriptNetworkRule::new()));
1195    engine.register_rule(Box::new(UnboundedAllocationRule::new()));
1196}