1#![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
24pub 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
135pub 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
221pub 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
304pub 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
432pub 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
550pub 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
665pub 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
797pub 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 if function.name.contains("HardcodedHomePathRule") {
844 continue;
845 }
846
847 let body_str = function.body.join("\n");
848
849 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 if body_str.contains("\"~") && !body_str.contains("\"~/") {
868 found_hardcoded = true;
869 }
870
871 if !found_hardcoded {
872 continue;
873 }
874
875 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
917pub 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 fn network_patterns() -> &'static [(&'static str, &'static str)] {
946 &[
947 ("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 ("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 ("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 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 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 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 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1048 continue;
1049 }
1050
1051 let is_safe = Self::safe_patterns().iter().any(|p| line.contains(p));
1053 if is_safe {
1054 continue;
1055 }
1056
1057 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; }
1077 }
1078 }
1079
1080 findings
1081 }
1082}
1083
1084pub 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
1180pub 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}